// // Copyright 2022 DXOS.org // import { describe, expect, test } from 'vitest'; import { Keyring } from '@dxos/keyring'; import { type PublicKey } from '@dxos/keys'; import { AdmittedFeed, type Chain, MembershipPolicy, SpaceMember } from '@dxos/protocols/proto/dxos/halo/credentials'; import { createCredential, verifyCredential } from '../credentials'; import { SpaceStateMachine } from './space-state-machine'; describe('SpaceStateMachine', () => { test('basic space creation', async () => { const keyring = new Keyring(); const space = await keyring.createKey(); const identity = await keyring.createKey(); const device = await keyring.createKey(); const feed = await keyring.createKey(); const spaceState = new SpaceStateMachine(space); expect( await spaceState.process( await createCredential({ issuer: space, subject: space, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': space, 'membershipPolicy': MembershipPolicy.INVITE, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); expect( await spaceState.process( await createCredential({ issuer: space, subject: identity, assertion: { '@type': 'dxos.halo.credentials.SpaceMember', 'spaceKey': space, 'role': SpaceMember.Role.ADMIN, 'genesisFeedKey': feed, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); const chain: Chain = { credential: await createCredential({ assertion: { '@type': 'dxos.halo.credentials.AuthorizedDevice', 'deviceKey': device, 'identityKey': identity, }, subject: device, issuer: identity, signer: keyring, }), }; expect( await spaceState.process( await createCredential({ issuer: identity, subject: feed, assertion: { '@type': 'dxos.halo.credentials.AdmittedFeed', 'spaceKey': space, 'identityKey': identity, 'deviceKey': device, 'designation': AdmittedFeed.Designation.CONTROL, }, signer: keyring, signingKey: device, chain, }), { sourceFeed: feed }, ), ).toEqual(true); expect(spaceState.genesisCredential).toBeDefined(); expect(Array.from(spaceState.members.values())).toMatchObject([ { key: identity, assertion: { spaceKey: space, role: SpaceMember.Role.ADMIN, }, }, ]); expect(Array.from(spaceState.feeds.values())).toMatchObject([ { key: feed, assertion: { spaceKey: space, identityKey: identity, deviceKey: device, designation: AdmittedFeed.Designation.CONTROL, }, }, ]); expect(spaceState.credentials).toHaveLength(3); }); test('admitting a member', async () => { const keyring = new Keyring(); const space = await keyring.createKey(); const identity = await keyring.createKey(); const device = await keyring.createKey(); const feed = await keyring.createKey(); const identity2 = await keyring.createKey(); const spaceState = new SpaceStateMachine(space); // Create the space genesis credential.` expect( await spaceState.process( await createCredential({ issuer: space, subject: space, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': space, 'membershipPolicy': MembershipPolicy.INVITE, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); // // Create the space member credential. expect( await spaceState.process( await createCredential({ issuer: space, subject: identity, assertion: { '@type': 'dxos.halo.credentials.SpaceMember', 'spaceKey': space, 'role': SpaceMember.Role.ADMIN, 'genesisFeedKey': feed, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); const chain: Chain = { credential: await createCredential({ assertion: { '@type': 'dxos.halo.credentials.AuthorizedDevice', 'deviceKey': device, 'identityKey': identity, }, subject: device, issuer: identity, signer: keyring, }), }; expect( await spaceState.process( await createCredential({ issuer: identity, subject: identity2, assertion: { '@type': 'dxos.halo.credentials.SpaceMember', 'spaceKey': space, 'role': SpaceMember.Role.EDITOR, 'genesisFeedKey': feed, }, signer: keyring, signingKey: device, chain, }), { sourceFeed: feed }, ), ).toEqual(true); expect(spaceState.genesisCredential).toBeDefined(); const comparator = (m1: { key: PublicKey }, m2: { key: PublicKey }) => m1.key.toHex().localeCompare(m2.key.toHex()); expect(Array.from(spaceState.members.values()).sort(comparator)).toMatchObject( [ { key: identity, assertion: { spaceKey: space, role: SpaceMember.Role.ADMIN, }, }, { key: identity2, assertion: { spaceKey: space, role: SpaceMember.Role.EDITOR, }, }, ].sort(comparator), ); expect(Array.from(spaceState.feeds.values())).toMatchObject([]); expect(spaceState.credentials).toHaveLength(3); }); test('space genesis with tags', async () => { const keyring = new Keyring(); const space = await keyring.createKey(); const identity = await keyring.createKey(); const feed = await keyring.createKey(); const spaceState = new SpaceStateMachine(space); expect(spaceState.tags).toEqual([]); expect( await spaceState.process( await createCredential({ issuer: space, subject: space, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': space, 'tags': ['personal', 'test'], 'membershipPolicy': MembershipPolicy.INVITE, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); expect(spaceState.tags).toEqual(['personal', 'test']); }); test('space genesis without tags returns empty array', async () => { const keyring = new Keyring(); const space = await keyring.createKey(); const feed = await keyring.createKey(); const spaceState = new SpaceStateMachine(space); await spaceState.process( await createCredential({ issuer: space, subject: space, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': space, 'membershipPolicy': MembershipPolicy.INVITE, }, signer: keyring, }), { sourceFeed: feed }, ); expect(spaceState.tags).toEqual([]); }); test('storing device credentials and building a chain', async () => { const keyring = new Keyring(); const haloSpace = await keyring.createKey(); const identity = await keyring.createKey(); const device1 = await keyring.createKey(); const device2 = await keyring.createKey(); const feed = await keyring.createKey(); const haloState = new SpaceStateMachine(haloSpace); // Create the space genesis credential. expect( await haloState.process( await createCredential({ issuer: haloSpace, subject: haloSpace, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': haloSpace, 'membershipPolicy': MembershipPolicy.INVITE, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); // Admit the identity to the space. expect( await haloState.process( await createCredential({ issuer: haloSpace, subject: identity, assertion: { '@type': 'dxos.halo.credentials.SpaceMember', 'spaceKey': haloSpace, 'role': SpaceMember.Role.ADMIN, 'genesisFeedKey': feed, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); // Assign the HALO space to the identity. expect( await haloState.process( await createCredential({ issuer: identity, subject: identity, assertion: { '@type': 'dxos.halo.credentials.HaloSpace', 'identityKey': identity, 'haloKey': haloSpace, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); // Admit device2 to the identity. expect( await haloState.process( await createCredential({ assertion: { '@type': 'dxos.halo.credentials.AuthorizedDevice', 'deviceKey': device1, 'identityKey': identity, }, subject: device1, issuer: identity, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); // Admit device1 to the identity. expect( await haloState.process( await createCredential({ assertion: { '@type': 'dxos.halo.credentials.AuthorizedDevice', 'deviceKey': device2, 'identityKey': identity, }, subject: device2, issuer: identity, signingKey: device1, // Create the keychain for device1 using credentials from the space. chain: { credential: haloState.credentials.find( (c) => c.subject.assertion['@type'] === 'dxos.halo.credentials.AuthorizedDevice' && c.subject.id.equals(device1), )!, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(true); // Issue a feed admit credential using the chain, const credential = await createCredential({ assertion: { '@type': 'dxos.halo.credentials.AdmittedFeed', 'spaceKey': haloSpace, 'deviceKey': device2, 'designation': AdmittedFeed.Designation.CONTROL, 'identityKey': identity, }, issuer: identity, signer: keyring, subject: feed, signingKey: device2, // Create the keychain for device2 using credentials from the space. chain: { credential: haloState.credentials.find( (c) => c.subject.assertion['@type'] === 'dxos.halo.credentials.AuthorizedDevice' && c.subject.id.equals(device2), )!, }, }); expect(await verifyCredential(credential)).toEqual({ kind: 'pass' }); }); test('space genesis with membership policy', async () => { const keyring = new Keyring(); const space = await keyring.createKey(); const feed = await keyring.createKey(); const spaceState = new SpaceStateMachine(space); await spaceState.process( await createCredential({ issuer: space, subject: space, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': space, 'membershipPolicy': MembershipPolicy.LOCKED, }, signer: keyring, }), { sourceFeed: feed }, ); expect(spaceState.membershipPolicy).toEqual(MembershipPolicy.LOCKED); }); test('space genesis without membership policy defaults to INVITE', async () => { const keyring = new Keyring(); const space = await keyring.createKey(); const feed = await keyring.createKey(); const spaceState = new SpaceStateMachine(space); // MembershipPolicy.INVITE is the proto zero-value default. // Existing spaces without the field will have this value. await spaceState.process( await createCredential({ issuer: space, subject: space, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': space, 'membershipPolicy': MembershipPolicy.INVITE, }, signer: keyring, }), { sourceFeed: feed }, ); expect(spaceState.membershipPolicy).toEqual(MembershipPolicy.INVITE); }); test('locked space rejects new members', async ({ expect }) => { const keyring = new Keyring(); const space = await keyring.createKey(); const identity = await keyring.createKey(); const identity2 = await keyring.createKey(); const feed = await keyring.createKey(); const spaceState = new SpaceStateMachine(space); await spaceState.process( await createCredential({ issuer: space, subject: space, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': space, 'membershipPolicy': MembershipPolicy.LOCKED, }, signer: keyring, }), { sourceFeed: feed }, ); await spaceState.process( await createCredential({ issuer: space, subject: identity, assertion: { '@type': 'dxos.halo.credentials.SpaceMember', 'spaceKey': space, 'role': SpaceMember.Role.OWNER, 'genesisFeedKey': feed, }, signer: keyring, }), { sourceFeed: feed }, ); // Attempt to add a second member — should be rejected. expect( await spaceState.process( await createCredential({ issuer: space, subject: identity2, assertion: { '@type': 'dxos.halo.credentials.SpaceMember', 'spaceKey': space, 'role': SpaceMember.Role.EDITOR, 'genesisFeedKey': feed, }, signer: keyring, }), { sourceFeed: feed }, ), ).toEqual(false); expect(spaceState.members.size).toEqual(1); }); test('locked space allows admitted feeds for existing members', async ({ expect }) => { const keyring = new Keyring(); const space = await keyring.createKey(); const identity = await keyring.createKey(); const device = await keyring.createKey(); const feed = await keyring.createKey(); const newFeed = await keyring.createKey(); const spaceState = new SpaceStateMachine(space); await spaceState.process( await createCredential({ issuer: space, subject: space, assertion: { '@type': 'dxos.halo.credentials.SpaceGenesis', 'spaceKey': space, 'membershipPolicy': MembershipPolicy.LOCKED, }, signer: keyring, }), { sourceFeed: feed }, ); await spaceState.process( await createCredential({ issuer: space, subject: identity, assertion: { '@type': 'dxos.halo.credentials.SpaceMember', 'spaceKey': space, 'role': SpaceMember.Role.OWNER, 'genesisFeedKey': feed, }, signer: keyring, }), { sourceFeed: feed }, ); const chain: Chain = { credential: await createCredential({ assertion: { '@type': 'dxos.halo.credentials.AuthorizedDevice', 'deviceKey': device, 'identityKey': identity, }, subject: device, issuer: identity, signer: keyring, }), }; // AdmittedFeed should still work on locked space. expect( await spaceState.process( await createCredential({ issuer: identity, subject: newFeed, assertion: { '@type': 'dxos.halo.credentials.AdmittedFeed', 'spaceKey': space, 'identityKey': identity, 'deviceKey': device, 'designation': AdmittedFeed.Designation.CONTROL, }, signer: keyring, signingKey: device, chain, }), { sourceFeed: feed }, ), ).toEqual(true); expect(spaceState.feeds.size).toEqual(1); }); });