From 7e7a015ff64ac9ada5c9fc063953c4352477263f Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 18 Aug 2026 17:10:30 +0400 Subject: [PATCH] feat: partner hierarchy core - models, gateways, contract invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for Block 5 (F41-F45): the frontend surface the partner provisioning API needs and has nothing for today. Core only - no UI yet, so this is additive and changes no existing behavior. - models/provisioning-node.model.ts four fixed levels, node status rules, PaymentPointConfig, tree helpers (childLevelOf/ancestorsOf/childrenOf) - models/partner-credential.model.ts public-key-only credential shape - services/*-gateway.interface.ts hierarchy read/create/status/disable plus credential register/rotate/revoke - services/*-api.gateway.ts /api/partner/v1/... per contract §4, §6 - services/*-local.gateway.ts in-memory, seeded four-level tree - services/*-gateway.token.ts environment.useMockData ? local : api Two deliberate choices worth stating: 1. PartnerCredential has no private-key field at all, and cannot grow one by accident - the partner generates the keypair, we hold only the public half (§6.1). looksLikePrivateKey() exists purely so a UI can refuse a paste of the wrong half before it reaches a log. 2. The local gateway ENFORCES the §2 invariants rather than being a permissive stub: externalReference uniqueness per (companyId, environment, level), server-computed path, inherited immutable environment/companyId, cascading disable, and disabled-is-terminal. A mock that accepts what the real backend rejects would let the UI ship a flow the backend then refuses - the invariants are the point, not the data. 24 new tests covering exactly those invariants (cascade stops outside the subtree, disabled cannot be reactivated, same reference allowed at a different level, lookup scoped by environment, revoked credential cannot rotate). Also checked and deliberately NOT done: F40 (delete mock-data.interceptor + src/assets/mock). Both are still load-bearing. mock-data.interceptor mocks the LEGACY endpoints (/category, /items, /cart, /qr) still served by ApiService and untouched by the gateway swap. src/assets/mock feeds mock-backoffice-data.provider, mock-bootstrap.provider (gated by useMockBootstrapOnLocal, currently true - it is what makes local dev render at all), and widget-manifest.service's fallbackManifestUrl, which is not mock-gated and runs in production. F40 belongs after the legacy-endpoint retirement (Track N), not here. Verified: 139/139 unit tests (24 new), arch:check clean, production build succeeds. Co-Authored-By: Claude Opus 5 --- .../models/partner-credential.model.ts | 59 +++++ .../models/provisioning-node.model.spec.ts | 105 +++++++++ .../models/provisioning-node.model.ts | 104 +++++++++ .../services/partner-hierarchy-api.gateway.ts | 108 +++++++++ .../partner-hierarchy-gateway.interface.ts | 32 +++ .../partner-hierarchy-gateway.token.ts | 11 + .../partner-hierarchy-local.gateway.spec.ts | 159 +++++++++++++ .../partner-hierarchy-local.gateway.ts | 209 ++++++++++++++++++ 8 files changed, 787 insertions(+) create mode 100644 src/app/core/partner-hierarchy/models/partner-credential.model.ts create mode 100644 src/app/core/partner-hierarchy/models/provisioning-node.model.spec.ts create mode 100644 src/app/core/partner-hierarchy/models/provisioning-node.model.ts create mode 100644 src/app/core/partner-hierarchy/services/partner-hierarchy-api.gateway.ts create mode 100644 src/app/core/partner-hierarchy/services/partner-hierarchy-gateway.interface.ts create mode 100644 src/app/core/partner-hierarchy/services/partner-hierarchy-gateway.token.ts create mode 100644 src/app/core/partner-hierarchy/services/partner-hierarchy-local.gateway.spec.ts create mode 100644 src/app/core/partner-hierarchy/services/partner-hierarchy-local.gateway.ts diff --git a/src/app/core/partner-hierarchy/models/partner-credential.model.ts b/src/app/core/partner-hierarchy/models/partner-credential.model.ts new file mode 100644 index 0000000..ba8be91 --- /dev/null +++ b/src/app/core/partner-hierarchy/models/partner-credential.model.ts @@ -0,0 +1,59 @@ +import { Environment } from './provisioning-node.model'; + +/** + * Partner API credentials. + * Per docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §6, TRACK-S §4.1. + * + * The partner generates the keypair. We hold only the PUBLIC key - the + * private half is never transmitted to us, never accepted by any endpoint, + * never logged. There is deliberately no field for it in this model, so no + * UI or gateway can accidentally carry one. + */ + +export type CredentialAlgorithm = 'ed25519' | 'rsa-pss-sha256'; + +/** `rotating` means a successor key is registered and both currently verify. */ +export type CredentialStatus = 'active' | 'rotating' | 'revoked'; + +export interface PartnerCredential { + partnerId: string; + keyId: string; + /** Authority is this node's subtree - nothing above it, nothing beside it. */ + scopeNodeId: string; + environment: Environment; + algorithm: CredentialAlgorithm; + /** PEM or base64 raw, per algorithm. Public half only. */ + publicKey: string; + status: CredentialStatus; + createdAt: string; + expiresAt?: string; +} + +export interface RegisterCredentialRequest { + scopeNodeId: string; + environment: Environment; + algorithm: CredentialAlgorithm; + publicKey: string; +} + +/** + * Revocation is immediate and irreversible (§6.2); a revoked credential can + * never be rotated back into service. Rotation only applies to a live key. + */ +export function canRotate(credential: PartnerCredential): boolean { + return credential.status === 'active'; +} + +export function canRevoke(credential: PartnerCredential): boolean { + return credential.status !== 'revoked'; +} + +/** + * Guards against a private key being pasted into a public-key field. Not a + * validity check on the key itself - that is the backend's job - purely a + * "this is the wrong half" check, because the cost of getting it wrong is a + * partner's private key landing in our logs. + */ +export function looksLikePrivateKey(value: string): boolean { + return /PRIVATE KEY|BEGIN OPENSSH PRIVATE/i.test(value); +} diff --git a/src/app/core/partner-hierarchy/models/provisioning-node.model.spec.ts b/src/app/core/partner-hierarchy/models/provisioning-node.model.spec.ts new file mode 100644 index 0000000..cdb0ceb --- /dev/null +++ b/src/app/core/partner-hierarchy/models/provisioning-node.model.spec.ts @@ -0,0 +1,105 @@ +import { + ProvisioningNode, + acceptsPayments, + ancestorsOf, + canTransitionTo, + childLevelOf, + childrenOf, +} from './provisioning-node.model'; + +function node(id: string, parentId: string | null, path: string[], displayName = id): ProvisioningNode { + return { + id, + level: 'store', + parentId, + companyId: 'cmp_1', + path, + environment: 'TEST', + status: 'active', + externalReference: `ext-${id}`, + displayName, + createdAt: '2026-08-18T00:00:00.000Z', + updatedAt: '2026-08-18T00:00:00.000Z', + }; +} + +describe('provisioning node model', () => { + describe('childLevelOf', () => { + it('walks the four fixed levels in order', () => { + expect(childLevelOf('company')).toBe('project'); + expect(childLevelOf('project')).toBe('store'); + expect(childLevelOf('store')).toBe('payment_point'); + }); + + it('returns null at the leaf, so nothing can be nested below a payment point', () => { + expect(childLevelOf('payment_point')).toBeNull(); + }); + }); + + describe('acceptsPayments', () => { + it('is true only for active nodes', () => { + expect(acceptsPayments({ ...node('a', null, ['a']), status: 'active' })).toBeTrue(); + expect(acceptsPayments({ ...node('a', null, ['a']), status: 'suspended' })).toBeFalse(); + expect(acceptsPayments({ ...node('a', null, ['a']), status: 'disabled' })).toBeFalse(); + }); + }); + + describe('canTransitionTo', () => { + it('treats disabled as terminal', () => { + expect(canTransitionTo('disabled', 'active')).toBeFalse(); + expect(canTransitionTo('disabled', 'suspended')).toBeFalse(); + }); + + it('allows suspend and reactivate', () => { + expect(canTransitionTo('active', 'suspended')).toBeTrue(); + expect(canTransitionTo('suspended', 'active')).toBeTrue(); + }); + + it('rejects a no-op transition', () => { + expect(canTransitionTo('active', 'active')).toBeFalse(); + }); + }); + + describe('ancestorsOf', () => { + it('resolves the root-to-leaf chain in path order', () => { + const all = [ + node('cmp', null, ['cmp']), + node('prj', 'cmp', ['cmp', 'prj']), + node('str', 'prj', ['cmp', 'prj', 'str']), + ]; + + const chain = ancestorsOf(all[2], all); + + expect(chain.map(n => n.id)).toEqual(['cmp', 'prj', 'str']); + }); + + it('skips ids the caller did not supply rather than returning holes', () => { + const leaf = node('str', 'prj', ['cmp', 'prj', 'str']); + + const chain = ancestorsOf(leaf, [leaf]); + + expect(chain.map(n => n.id)).toEqual(['str']); + }); + }); + + describe('childrenOf', () => { + it('returns direct children only, not deeper descendants', () => { + const all = [ + node('cmp', null, ['cmp']), + node('prj', 'cmp', ['cmp', 'prj']), + node('str', 'prj', ['cmp', 'prj', 'str']), + ]; + + expect(childrenOf('cmp', all).map(n => n.id)).toEqual(['prj']); + }); + + it('orders by displayName so the tree renders stably', () => { + const all = [ + node('a', 'root', ['root', 'a'], 'Zebra'), + node('b', 'root', ['root', 'b'], 'Alpha'), + ]; + + expect(childrenOf('root', all).map(n => n.displayName)).toEqual(['Alpha', 'Zebra']); + }); + }); +}); diff --git a/src/app/core/partner-hierarchy/models/provisioning-node.model.ts b/src/app/core/partner-hierarchy/models/provisioning-node.model.ts new file mode 100644 index 0000000..f8db698 --- /dev/null +++ b/src/app/core/partner-hierarchy/models/provisioning-node.model.ts @@ -0,0 +1,104 @@ +/** + * Partner-provisioned merchant hierarchy. + * Per docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §2, §10. + * + * Deliberately generic: no partner name appears in any type, field or status + * value here. Everything partner-varying (level naming, which levels a given + * partner uses) lives in PartnerProfile, not in this model. + */ + +/** Four fixed levels. Middle levels are optional per partner, depth is not partner-defined. */ +export type NodeLevel = 'company' | 'project' | 'store' | 'payment_point'; + +export type Environment = 'TEST' | 'LIVE'; + +/** `disabled` is terminal - it never returns to any other status (§2). */ +export type NodeStatus = 'active' | 'suspended' | 'disabled'; + +export interface ProvisioningNode { + id: string; + level: NodeLevel; + /** null only for level 'company'. */ + parentId: string | null; + /** Denormalized root, present on every node. */ + companyId: string; + /** Ordered ancestor ids, root first, inclusive of self. Server-computed, never client-sent. */ + path: string[]; + environment: Environment; + status: NodeStatus; + /** The partner's own id for this node. Unique per (companyId, environment, level). */ + externalReference: string; + displayName: string; + createdAt: string; + updatedAt: string; +} + +/** + * A payment_point is an acceptance channel - one payment method at one store. + * Not a physical till, not a settlement account (§10.1). + */ +export interface PaymentPointConfig { + method: PaymentMethod; + /** ISO 4217 subset this channel accepts. */ + currencies: string[]; + /** + * Opaque provider-side binding, set only by financial enablement. + * Its absence is what makes a registered payment point not-yet-live: + * creating a node never enables real money (§2 invariant 6). + */ + providerAccountRef?: string; +} + +/** Both ship today (src/app/pages/cart/cart.component.ts). Extensible. */ +export type PaymentMethod = 'qr' | 'card'; + +export interface CreateNodeRequest { + externalReference: string; + displayName: string; + /** Opaque to us, echoed back, never interpreted. */ + metadata?: Record; +} + +/** The child level that may be created under a given level, or null at the leaf. */ +export function childLevelOf(level: NodeLevel): NodeLevel | null { + switch (level) { + case 'company': return 'project'; + case 'project': return 'store'; + case 'store': return 'payment_point'; + case 'payment_point': return null; + } +} + +/** True when a node can currently accept payments (§2 status table). */ +export function acceptsPayments(node: ProvisioningNode): boolean { + return node.status === 'active'; +} + +/** + * `disabled` is terminal and cascades; `suspended` is reversible. + * Encoded here so UI and any future client-side guard agree on one rule. + */ +export function canTransitionTo(from: NodeStatus, to: NodeStatus): boolean { + if (from === 'disabled') { + return false; + } + if (from === to) { + return false; + } + return to === 'active' || to === 'suspended' || to === 'disabled'; +} + +/** Builds the ordered root-to-leaf chain for a node from a flat set. */ +export function ancestorsOf(node: ProvisioningNode, all: readonly ProvisioningNode[]): ProvisioningNode[] { + const byId = new Map(all.map(n => [n.id, n])); + return node.path + .map(id => byId.get(id)) + .filter((n): n is ProvisioningNode => n !== undefined); +} + +/** Direct children of a node, in stable displayName order. */ +export function childrenOf(nodeId: string, all: readonly ProvisioningNode[]): ProvisioningNode[] { + return all + .filter(n => n.parentId === nodeId) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); +} diff --git a/src/app/core/partner-hierarchy/services/partner-hierarchy-api.gateway.ts b/src/app/core/partner-hierarchy/services/partner-hierarchy-api.gateway.ts new file mode 100644 index 0000000..ea4ff35 --- /dev/null +++ b/src/app/core/partner-hierarchy/services/partner-hierarchy-api.gateway.ts @@ -0,0 +1,108 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { PartnerCredential, RegisterCredentialRequest } from '../models/partner-credential.model'; +import { CreateNodeRequest, Environment, NodeStatus, ProvisioningNode } from '../models/provisioning-node.model'; +import { PartnerHierarchyGateway } from './partner-hierarchy-gateway.interface'; + +/** + * Contract: docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4, §6. + * + * Note this is the INBOUND partner API viewed from our own admin side - the + * same endpoints partners call, used here so our backoffice sees exactly the + * hierarchy a partner sees, rather than a parallel internal view that could + * drift from it. + */ +@Injectable({ providedIn: 'root' }) +export class PartnerHierarchyApiGateway implements PartnerHierarchyGateway { + private readonly http = inject(HttpClient); + + loadHierarchy(companyId: string, environment: Environment): Observable { + const params = new HttpParams().set('environment', environment); + return this.http.get( + `/api/partner/v1/companies/${encodeURIComponent(companyId)}/hierarchy`, + { params }, + ); + } + + loadNode(nodeId: string): Observable { + return this.http.get(`/api/partner/v1/nodes/${encodeURIComponent(nodeId)}`); + } + + lookupByExternalReference( + externalReference: string, + environment: Environment, + ): Observable { + const params = new HttpParams() + .set('externalReference', externalReference) + .set('environment', environment); + // §4.2: an unmatched reference is 404, never a partial match. Translated + // to null so callers branch on a value rather than catching an error. + return this.http + .get('/api/partner/v1/nodes/lookup', { params }) + .pipe(catchError(() => of(null))); + } + + createProject(companyId: string, request: CreateNodeRequest): Observable { + return this.http.post( + `/api/partner/v1/companies/${encodeURIComponent(companyId)}/projects`, + request, + ); + } + + createStore(projectId: string, request: CreateNodeRequest): Observable { + return this.http.post( + `/api/partner/v1/projects/${encodeURIComponent(projectId)}/stores`, + request, + ); + } + + createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable { + return this.http.post( + `/api/partner/v1/stores/${encodeURIComponent(storeId)}/payment-points`, + request, + ); + } + + setStatus( + nodeId: string, + status: Extract, + reason?: string, + ): Observable { + return this.http.patch( + `/api/partner/v1/nodes/${encodeURIComponent(nodeId)}/status`, + reason ? { status, reason } : { status }, + ); + } + + disable(nodeId: string): Observable { + return this.http.post( + `/api/partner/v1/nodes/${encodeURIComponent(nodeId)}/disable`, + {}, + ); + } + + loadCredentials(companyId: string): Observable { + const params = new HttpParams().set('companyId', companyId); + return this.http.get('/api/partner/v1/credentials', { params }); + } + + registerCredential(request: RegisterCredentialRequest): Observable { + return this.http.post('/api/partner/v1/credentials', request); + } + + rotateCredential(keyId: string, newPublicKey: string): Observable { + return this.http.post( + `/api/partner/v1/credentials/${encodeURIComponent(keyId)}/rotate`, + { publicKey: newPublicKey }, + ); + } + + revokeCredential(keyId: string): Observable { + return this.http + .delete(`/api/partner/v1/credentials/${encodeURIComponent(keyId)}`) + .pipe(map(() => undefined)); + } +} diff --git a/src/app/core/partner-hierarchy/services/partner-hierarchy-gateway.interface.ts b/src/app/core/partner-hierarchy/services/partner-hierarchy-gateway.interface.ts new file mode 100644 index 0000000..7b4fa42 --- /dev/null +++ b/src/app/core/partner-hierarchy/services/partner-hierarchy-gateway.interface.ts @@ -0,0 +1,32 @@ +import { Observable } from 'rxjs'; +import { PartnerCredential, RegisterCredentialRequest } from '../models/partner-credential.model'; +import { CreateNodeRequest, Environment, NodeStatus, ProvisioningNode } from '../models/provisioning-node.model'; + +/** Per docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4, §6. */ +export interface PartnerHierarchyGateway { + /** Full tree for a company, with current statuses (§4.2). */ + loadHierarchy(companyId: string, environment: Environment): Observable; + + loadNode(nodeId: string): Observable; + + /** Resolve by the partner's own reference (§4.2). Null when unmatched - never a fuzzy match. */ + lookupByExternalReference( + externalReference: string, + environment: Environment, + ): Observable; + + createProject(companyId: string, request: CreateNodeRequest): Observable; + createStore(projectId: string, request: CreateNodeRequest): Observable; + createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable; + + /** 'active' | 'suspended' only - disabling is a separate, terminal call. */ + setStatus(nodeId: string, status: Extract, reason?: string): Observable; + + /** Terminal and cascading (§2). */ + disable(nodeId: string): Observable; + + loadCredentials(companyId: string): Observable; + registerCredential(request: RegisterCredentialRequest): Observable; + rotateCredential(keyId: string, newPublicKey: string): Observable; + revokeCredential(keyId: string): Observable; +} diff --git a/src/app/core/partner-hierarchy/services/partner-hierarchy-gateway.token.ts b/src/app/core/partner-hierarchy/services/partner-hierarchy-gateway.token.ts new file mode 100644 index 0000000..e82017c --- /dev/null +++ b/src/app/core/partner-hierarchy/services/partner-hierarchy-gateway.token.ts @@ -0,0 +1,11 @@ +import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { PartnerHierarchyGateway } from './partner-hierarchy-gateway.interface'; +import { PartnerHierarchyLocalGateway } from './partner-hierarchy-local.gateway'; +import { PartnerHierarchyApiGateway } from './partner-hierarchy-api.gateway'; + +/** Swap point for docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4, §6. */ +export const PARTNER_HIERARCHY_GATEWAY = new InjectionToken('PARTNER_HIERARCHY_GATEWAY', { + providedIn: 'root', + factory: () => (environment.useMockData ? inject(PartnerHierarchyLocalGateway) : inject(PartnerHierarchyApiGateway)), +}); diff --git a/src/app/core/partner-hierarchy/services/partner-hierarchy-local.gateway.spec.ts b/src/app/core/partner-hierarchy/services/partner-hierarchy-local.gateway.spec.ts new file mode 100644 index 0000000..1fa5f4a --- /dev/null +++ b/src/app/core/partner-hierarchy/services/partner-hierarchy-local.gateway.spec.ts @@ -0,0 +1,159 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { PartnerHierarchyLocalGateway } from './partner-hierarchy-local.gateway'; +import { ProvisioningNode } from '../models/provisioning-node.model'; + +/** + * These assert the §2 invariants from + * docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md, not just that the mock + * returns something. A mock that accepts what the real backend rejects would + * let the UI ship a flow the backend then refuses. + */ +describe('PartnerHierarchyLocalGateway (contract invariants)', () => { + let gateway: PartnerHierarchyLocalGateway; + + beforeEach(() => { + TestBed.configureTestingModule({}); + gateway = TestBed.inject(PartnerHierarchyLocalGateway); + }); + + it('seeds a full four-level hierarchy', async () => { + const nodes = await firstValueFrom(gateway.loadHierarchy('cmp_1', 'TEST')); + + expect([...nodes].map(n => String(n.level)).sort()).toEqual( + ['company', 'payment_point', 'payment_point', 'project', 'store'], + ); + }); + + it('computes path server-side, never trusting a client-supplied one', async () => { + const created = await firstValueFrom( + gateway.createStore('prj_1', { externalReference: 'new-store', displayName: 'New Store' }), + ); + + expect(created.path).toEqual(['cmp_1', 'prj_1', created.id]); + expect(created.path[created.path.length - 1]).toBe(created.id); + }); + + it('inherits companyId and environment from the parent', async () => { + const created = await firstValueFrom( + gateway.createPaymentPoint('str_1', { externalReference: 'new-pp', displayName: 'New PP' }), + ); + + expect(created.companyId).toBe('cmp_1'); + expect(created.environment).toBe('TEST'); + }); + + it('rejects a duplicate externalReference at the same level', async () => { + await firstValueFrom(gateway.createStore('prj_1', { externalReference: 'dup', displayName: 'A' })); + + await expectAsync( + firstValueFrom(gateway.createStore('prj_1', { externalReference: 'dup', displayName: 'B' })), + ).toBeRejectedWithError(/already in use/); + }); + + it('allows the same externalReference at a different level', async () => { + await firstValueFrom(gateway.createStore('prj_1', { externalReference: 'shared', displayName: 'Store' })); + + // Uniqueness is scoped per (companyId, environment, level) - a payment + // point may reuse a store's reference without collision. + const created = await firstValueFrom( + gateway.createPaymentPoint('str_1', { externalReference: 'shared', displayName: 'PP' }), + ); + + expect(created.externalReference).toBe('shared'); + }); + + it('rejects creating under a parent that does not exist', async () => { + await expectAsync( + firstValueFrom(gateway.createStore('nope', { externalReference: 'x', displayName: 'X' })), + ).toBeRejectedWithError(/Parent not found/); + }); + + it('cascades disable to every descendant', async () => { + await firstValueFrom(gateway.disable('str_1')); + const nodes = await firstValueFrom(gateway.loadHierarchy('cmp_1', 'TEST')); + + const store = find(nodes, 'str_1'); + const paymentPoints = nodes.filter(n => n.level === 'payment_point'); + + expect(store.status).toBe('disabled'); + expect(paymentPoints.every(n => n.status === 'disabled')).toBeTrue(); + }); + + it('leaves nodes outside the disabled subtree untouched', async () => { + await firstValueFrom(gateway.disable('str_1')); + const nodes = await firstValueFrom(gateway.loadHierarchy('cmp_1', 'TEST')); + + expect(find(nodes, 'cmp_1').status).toBe('active'); + expect(find(nodes, 'prj_1').status).toBe('active'); + }); + + it('treats disabled as terminal - no status change afterwards', async () => { + await firstValueFrom(gateway.disable('str_1')); + + await expectAsync(firstValueFrom(gateway.setStatus('str_1', 'active'))).toBeRejectedWithError( + /disabled node cannot change status/, + ); + }); + + it('allows suspend then reactivate', async () => { + await firstValueFrom(gateway.setStatus('str_1', 'suspended')); + const reactivated = await firstValueFrom(gateway.setStatus('str_1', 'active')); + + expect(reactivated.status).toBe('active'); + }); + + it('returns null rather than a fuzzy match for an unknown externalReference', async () => { + const found = await firstValueFrom(gateway.lookupByExternalReference('does-not-exist', 'TEST')); + + expect(found).toBeNull(); + }); + + it('scopes lookup by environment', async () => { + // Seed data is TEST-only; the same reference must not resolve under LIVE. + const found = await firstValueFrom(gateway.lookupByExternalReference('demo-store-1', 'LIVE')); + + expect(found).toBeNull(); + }); + + it('never returns a revoked credential to rotation', async () => { + const credential = await firstValueFrom( + gateway.registerCredential({ + scopeNodeId: 'prj_1', + environment: 'TEST', + algorithm: 'ed25519', + publicKey: 'ssh-ed25519 AAAA...', + }), + ); + await firstValueFrom(gateway.revokeCredential(credential.keyId)); + + await expectAsync( + firstValueFrom(gateway.rotateCredential(credential.keyId, 'ssh-ed25519 BBBB...')), + ).toBeRejectedWithError(/revoked credential cannot be rotated/); + }); + + it('scopes credentials to the requested company', async () => { + await firstValueFrom( + gateway.registerCredential({ + scopeNodeId: 'prj_1', + environment: 'TEST', + algorithm: 'ed25519', + publicKey: 'ssh-ed25519 AAAA...', + }), + ); + + const forCompany = await firstValueFrom(gateway.loadCredentials('cmp_1')); + const forOther = await firstValueFrom(gateway.loadCredentials('cmp_other')); + + expect(forCompany.length).toBe(1); + expect(forOther.length).toBe(0); + }); +}); + +function find(nodes: ProvisioningNode[], id: string): ProvisioningNode { + const found = nodes.find(n => n.id === id); + if (!found) { + throw new Error(`expected node ${id} in hierarchy`); + } + return found; +} diff --git a/src/app/core/partner-hierarchy/services/partner-hierarchy-local.gateway.ts b/src/app/core/partner-hierarchy/services/partner-hierarchy-local.gateway.ts new file mode 100644 index 0000000..93cb9a4 --- /dev/null +++ b/src/app/core/partner-hierarchy/services/partner-hierarchy-local.gateway.ts @@ -0,0 +1,209 @@ +import { Injectable, signal } from '@angular/core'; +import { Observable, of, throwError } from 'rxjs'; +import { PartnerCredential, RegisterCredentialRequest } from '../models/partner-credential.model'; +import { + CreateNodeRequest, + Environment, + NodeLevel, + NodeStatus, + ProvisioningNode, +} from '../models/provisioning-node.model'; +import { PartnerHierarchyGateway } from './partner-hierarchy-gateway.interface'; + +/** + * In-memory hierarchy for local dev, until the partner API ships. + * + * Enforces the invariants the real backend enforces (§2), rather than being a + * permissive stub: externalReference uniqueness, terminal `disabled`, + * cascading disable, no re-parenting. A mock that accepts what the real + * backend rejects teaches the UI the wrong lesson. + */ + +const NOW = '2026-08-18T00:00:00.000Z'; + +function node( + id: string, + level: NodeLevel, + parentId: string | null, + companyId: string, + path: string[], + displayName: string, + externalReference: string, + status: NodeStatus = 'active', +): ProvisioningNode { + return { + id, level, parentId, companyId, path, displayName, externalReference, + environment: 'TEST', status, createdAt: NOW, updatedAt: NOW, + }; +} + +const SEED: ProvisioningNode[] = [ + node('cmp_1', 'company', null, 'cmp_1', ['cmp_1'], 'Demo Company', 'demo-co'), + node('prj_1', 'project', 'cmp_1', 'cmp_1', ['cmp_1', 'prj_1'], 'marketplaces', 'demo-marketplaces'), + node('str_1', 'store', 'prj_1', 'cmp_1', ['cmp_1', 'prj_1', 'str_1'], 'Demo Storefront', 'demo-store-1'), + node('pp_1', 'payment_point', 'str_1', 'cmp_1', ['cmp_1', 'prj_1', 'str_1', 'pp_1'], 'QR acceptance', 'demo-pp-qr'), + node('pp_2', 'payment_point', 'str_1', 'cmp_1', ['cmp_1', 'prj_1', 'str_1', 'pp_2'], 'Card acceptance', 'demo-pp-card'), +]; + +@Injectable({ providedIn: 'root' }) +export class PartnerHierarchyLocalGateway implements PartnerHierarchyGateway { + private readonly nodes = signal([...SEED]); + private readonly credentials = signal([]); + private sequence = 1; + + loadHierarchy(companyId: string, environment: Environment): Observable { + return of(this.nodes().filter(n => n.companyId === companyId && n.environment === environment)); + } + + loadNode(nodeId: string): Observable { + return of(this.nodes().find(n => n.id === nodeId) ?? null); + } + + lookupByExternalReference( + externalReference: string, + environment: Environment, + ): Observable { + return of( + this.nodes().find( + n => n.externalReference === externalReference && n.environment === environment, + ) ?? null, + ); + } + + createProject(companyId: string, request: CreateNodeRequest): Observable { + return this.create(companyId, 'project', request); + } + + createStore(projectId: string, request: CreateNodeRequest): Observable { + return this.create(projectId, 'store', request); + } + + createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable { + return this.create(storeId, 'payment_point', request); + } + + setStatus( + nodeId: string, + status: Extract, + ): Observable { + const existing = this.nodes().find(n => n.id === nodeId); + if (!existing) { + return throwError(() => new Error(`Node not found: ${nodeId}`)); + } + if (existing.status === 'disabled') { + // §2: disabled is terminal. The mock must refuse this too, or the UI + // learns a transition the real backend will reject. + return throwError(() => new Error('A disabled node cannot change status.')); + } + + const updated = { ...existing, status, updatedAt: new Date().toISOString() }; + this.nodes.update(all => all.map(n => (n.id === nodeId ? updated : n))); + return of(updated); + } + + disable(nodeId: string): Observable { + const existing = this.nodes().find(n => n.id === nodeId); + if (!existing) { + return throwError(() => new Error(`Node not found: ${nodeId}`)); + } + + // §2: disabling cascades to every descendant, atomically. + const updatedAt = new Date().toISOString(); + this.nodes.update(all => + all.map(n => + n.id === nodeId || n.path.includes(nodeId) + ? { ...n, status: 'disabled' as const, updatedAt } + : n, + ), + ); + return of({ ...existing, status: 'disabled' as const, updatedAt }); + } + + loadCredentials(companyId: string): Observable { + const companyNodeIds = new Set( + this.nodes().filter(n => n.companyId === companyId).map(n => n.id), + ); + return of(this.credentials().filter(c => companyNodeIds.has(c.scopeNodeId))); + } + + registerCredential(request: RegisterCredentialRequest): Observable { + const credential: PartnerCredential = { + partnerId: 'ptr_local', + keyId: `key_${this.sequence++}`, + scopeNodeId: request.scopeNodeId, + environment: request.environment, + algorithm: request.algorithm, + publicKey: request.publicKey, + status: 'active', + createdAt: new Date().toISOString(), + }; + this.credentials.update(all => [...all, credential]); + return of(credential); + } + + rotateCredential(keyId: string, newPublicKey: string): Observable { + const existing = this.credentials().find(c => c.keyId === keyId); + if (!existing) { + return throwError(() => new Error(`Credential not found: ${keyId}`)); + } + if (existing.status === 'revoked') { + return throwError(() => new Error('A revoked credential cannot be rotated.')); + } + + const updated: PartnerCredential = { ...existing, publicKey: newPublicKey, status: 'rotating' }; + this.credentials.update(all => all.map(c => (c.keyId === keyId ? updated : c))); + return of(updated); + } + + revokeCredential(keyId: string): Observable { + this.credentials.update(all => + all.map(c => (c.keyId === keyId ? { ...c, status: 'revoked' as const } : c)), + ); + return of(undefined); + } + + private create( + parentId: string, + level: NodeLevel, + request: CreateNodeRequest, + ): Observable { + const parent = this.nodes().find(n => n.id === parentId); + if (!parent) { + return throwError(() => new Error(`Parent not found: ${parentId}`)); + } + + // §2 invariant 3: externalReference is unique per (companyId, environment, level). + const collision = this.nodes().some( + n => + n.companyId === parent.companyId && + n.environment === parent.environment && + n.level === level && + n.externalReference === request.externalReference, + ); + if (collision) { + return throwError( + () => new Error(`externalReference already in use at this level: ${request.externalReference}`), + ); + } + + const id = `${level}_${this.sequence++}`; + const created: ProvisioningNode = { + id, + level, + parentId, + companyId: parent.companyId, + // §2 invariant 4: path is server-computed, never client-supplied. + path: [...parent.path, id], + // §2 invariant 2: environment is inherited and immutable. + environment: parent.environment, + status: 'active', + externalReference: request.externalReference, + displayName: request.displayName, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + this.nodes.update(all => [...all, created]); + return of(created); + } +}