feat: partner hierarchy core - models, gateways, contract invariants
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
src/app/core/partner-hierarchy/models/provisioning-node.model.ts
Normal file
104
src/app/core/partner-hierarchy/models/provisioning-node.model.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
@@ -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<ProvisioningNode[]> {
|
||||
const params = new HttpParams().set('environment', environment);
|
||||
return this.http.get<ProvisioningNode[]>(
|
||||
`/api/partner/v1/companies/${encodeURIComponent(companyId)}/hierarchy`,
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
loadNode(nodeId: string): Observable<ProvisioningNode | null> {
|
||||
return this.http.get<ProvisioningNode | null>(`/api/partner/v1/nodes/${encodeURIComponent(nodeId)}`);
|
||||
}
|
||||
|
||||
lookupByExternalReference(
|
||||
externalReference: string,
|
||||
environment: Environment,
|
||||
): Observable<ProvisioningNode | null> {
|
||||
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<ProvisioningNode>('/api/partner/v1/nodes/lookup', { params })
|
||||
.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
createProject(companyId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
|
||||
return this.http.post<ProvisioningNode>(
|
||||
`/api/partner/v1/companies/${encodeURIComponent(companyId)}/projects`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
createStore(projectId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
|
||||
return this.http.post<ProvisioningNode>(
|
||||
`/api/partner/v1/projects/${encodeURIComponent(projectId)}/stores`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
|
||||
return this.http.post<ProvisioningNode>(
|
||||
`/api/partner/v1/stores/${encodeURIComponent(storeId)}/payment-points`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
setStatus(
|
||||
nodeId: string,
|
||||
status: Extract<NodeStatus, 'active' | 'suspended'>,
|
||||
reason?: string,
|
||||
): Observable<ProvisioningNode> {
|
||||
return this.http.patch<ProvisioningNode>(
|
||||
`/api/partner/v1/nodes/${encodeURIComponent(nodeId)}/status`,
|
||||
reason ? { status, reason } : { status },
|
||||
);
|
||||
}
|
||||
|
||||
disable(nodeId: string): Observable<ProvisioningNode> {
|
||||
return this.http.post<ProvisioningNode>(
|
||||
`/api/partner/v1/nodes/${encodeURIComponent(nodeId)}/disable`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
loadCredentials(companyId: string): Observable<PartnerCredential[]> {
|
||||
const params = new HttpParams().set('companyId', companyId);
|
||||
return this.http.get<PartnerCredential[]>('/api/partner/v1/credentials', { params });
|
||||
}
|
||||
|
||||
registerCredential(request: RegisterCredentialRequest): Observable<PartnerCredential> {
|
||||
return this.http.post<PartnerCredential>('/api/partner/v1/credentials', request);
|
||||
}
|
||||
|
||||
rotateCredential(keyId: string, newPublicKey: string): Observable<PartnerCredential> {
|
||||
return this.http.post<PartnerCredential>(
|
||||
`/api/partner/v1/credentials/${encodeURIComponent(keyId)}/rotate`,
|
||||
{ publicKey: newPublicKey },
|
||||
);
|
||||
}
|
||||
|
||||
revokeCredential(keyId: string): Observable<void> {
|
||||
return this.http
|
||||
.delete(`/api/partner/v1/credentials/${encodeURIComponent(keyId)}`)
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
@@ -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<ProvisioningNode[]>;
|
||||
|
||||
loadNode(nodeId: string): Observable<ProvisioningNode | null>;
|
||||
|
||||
/** Resolve by the partner's own reference (§4.2). Null when unmatched - never a fuzzy match. */
|
||||
lookupByExternalReference(
|
||||
externalReference: string,
|
||||
environment: Environment,
|
||||
): Observable<ProvisioningNode | null>;
|
||||
|
||||
createProject(companyId: string, request: CreateNodeRequest): Observable<ProvisioningNode>;
|
||||
createStore(projectId: string, request: CreateNodeRequest): Observable<ProvisioningNode>;
|
||||
createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable<ProvisioningNode>;
|
||||
|
||||
/** 'active' | 'suspended' only - disabling is a separate, terminal call. */
|
||||
setStatus(nodeId: string, status: Extract<NodeStatus, 'active' | 'suspended'>, reason?: string): Observable<ProvisioningNode>;
|
||||
|
||||
/** Terminal and cascading (§2). */
|
||||
disable(nodeId: string): Observable<ProvisioningNode>;
|
||||
|
||||
loadCredentials(companyId: string): Observable<PartnerCredential[]>;
|
||||
registerCredential(request: RegisterCredentialRequest): Observable<PartnerCredential>;
|
||||
rotateCredential(keyId: string, newPublicKey: string): Observable<PartnerCredential>;
|
||||
revokeCredential(keyId: string): Observable<void>;
|
||||
}
|
||||
@@ -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<PartnerHierarchyGateway>('PARTNER_HIERARCHY_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(PartnerHierarchyLocalGateway) : inject(PartnerHierarchyApiGateway)),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ProvisioningNode[]>([...SEED]);
|
||||
private readonly credentials = signal<PartnerCredential[]>([]);
|
||||
private sequence = 1;
|
||||
|
||||
loadHierarchy(companyId: string, environment: Environment): Observable<ProvisioningNode[]> {
|
||||
return of(this.nodes().filter(n => n.companyId === companyId && n.environment === environment));
|
||||
}
|
||||
|
||||
loadNode(nodeId: string): Observable<ProvisioningNode | null> {
|
||||
return of(this.nodes().find(n => n.id === nodeId) ?? null);
|
||||
}
|
||||
|
||||
lookupByExternalReference(
|
||||
externalReference: string,
|
||||
environment: Environment,
|
||||
): Observable<ProvisioningNode | null> {
|
||||
return of(
|
||||
this.nodes().find(
|
||||
n => n.externalReference === externalReference && n.environment === environment,
|
||||
) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
createProject(companyId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
|
||||
return this.create(companyId, 'project', request);
|
||||
}
|
||||
|
||||
createStore(projectId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
|
||||
return this.create(projectId, 'store', request);
|
||||
}
|
||||
|
||||
createPaymentPoint(storeId: string, request: CreateNodeRequest): Observable<ProvisioningNode> {
|
||||
return this.create(storeId, 'payment_point', request);
|
||||
}
|
||||
|
||||
setStatus(
|
||||
nodeId: string,
|
||||
status: Extract<NodeStatus, 'active' | 'suspended'>,
|
||||
): Observable<ProvisioningNode> {
|
||||
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<ProvisioningNode> {
|
||||
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<PartnerCredential[]> {
|
||||
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<PartnerCredential> {
|
||||
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<PartnerCredential> {
|
||||
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<void> {
|
||||
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<ProvisioningNode> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user