You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
455 lines
13 KiB
455 lines
13 KiB
import { describe, expect, it } from 'vitest';
|
|
import { createActiveApp } from '../active-app.svelte';
|
|
import {
|
|
CACHE_EVENT_ALL,
|
|
CACHE_EVENT_INVALIDATE,
|
|
CACHE_POLICY_INTERACTIVE,
|
|
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
|
|
CACHE_SCOPE_TENANT,
|
|
type CacheEvent,
|
|
type ResolvedScopeValues
|
|
} from '$cach';
|
|
import { CONNECTION_STATE_CLOSED, CONNECTION_STATE_OPEN, createMockTransport } from '$conn';
|
|
import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE } from '$libs/http';
|
|
import type { StandardSchemaV1 } from '$libs/standard-schema';
|
|
import {
|
|
AUTH_AAL,
|
|
AUTH_AMR,
|
|
AUTH_ROUTE_PATHS,
|
|
AUTH_SESSION_STATUSES,
|
|
type AuthActorId,
|
|
type AuthCurrentView,
|
|
type AuthSessionId,
|
|
type AuthTenantId
|
|
} from '$libs/auth';
|
|
import { LogLevel, type LogEntry } from '$logr';
|
|
import {
|
|
PERMISSION_EFFECT_ALLOW,
|
|
PERMISSION_EFFECT_NOT_APPLICABLE,
|
|
actor,
|
|
allow,
|
|
and,
|
|
attr,
|
|
definePermSchema,
|
|
definePolicies,
|
|
rel,
|
|
type ResourceRef,
|
|
type SubjectRef
|
|
} from '$perm';
|
|
import { createEnginePermissions, createPermissionHttpHandlers } from '$svrs/perm';
|
|
import { createMemoryAdapter } from '$stor';
|
|
|
|
const PERM_ENDPOINT = 'https://ecosystem.test/api/permissions';
|
|
const HTTP_PROJECT_PATH = '/api/demo/project';
|
|
const TENANT_ID = 'tenant-acme';
|
|
const ACTOR_ID = 'actor-ada';
|
|
const PROJECT_ID = 'project-atlas';
|
|
const PROJECT_ACTION_UPDATE = 'project.update';
|
|
const PROJECT_RELATION_MEMBER = 'project.member';
|
|
const PROJECT_SCHEMA_VERSION = 'ProjectPayload:v1';
|
|
const ROLE_ADMIN = 'admin';
|
|
const ROLE_VIEWER = 'viewer';
|
|
const TIMER_KEY = 'ecosystem:test';
|
|
const CONNECTION_NAME = 'updates';
|
|
const LOG_CATEGORY = 'test.ecosystem';
|
|
const LOG_MESSAGE_BOOT = 'ecosystem.boot';
|
|
const JSON_PASSTHROUGH_SCHEMA: StandardSchemaV1<unknown, unknown> = {
|
|
'~standard': {
|
|
version: 1,
|
|
vendor: 'ecosystem-test',
|
|
validate(value) {
|
|
return { value };
|
|
}
|
|
}
|
|
};
|
|
|
|
interface ProjectResource extends ResourceRef {
|
|
readonly type: 'project';
|
|
readonly id: string;
|
|
readonly tenantId: string;
|
|
readonly locked: boolean;
|
|
}
|
|
|
|
interface ProjectPayload {
|
|
readonly id: string;
|
|
readonly tenantId: string;
|
|
readonly version: number;
|
|
}
|
|
|
|
interface DemoUser {
|
|
readonly id: string;
|
|
readonly email: string;
|
|
}
|
|
|
|
interface DemoSessionData {
|
|
readonly tenantId: string;
|
|
readonly permissionHash: string;
|
|
}
|
|
|
|
describe('ActiveApp — total ecosystem integration', () => {
|
|
it('wires auth, sess, perm, cach, http, stor, sium, fmts, fend, adom, timr, conn and logr', async () => {
|
|
const entries: LogEntry[] = [];
|
|
const authCurrent = createAuthCurrent();
|
|
let actorRole = ROLE_ADMIN;
|
|
const actorRef = (): SubjectRef => ({
|
|
type: 'user',
|
|
id: ACTOR_ID,
|
|
role: actorRole,
|
|
tenantIds: [TENANT_ID]
|
|
});
|
|
const resource: ProjectResource = {
|
|
type: 'project',
|
|
id: PROJECT_ID,
|
|
tenantId: TENANT_ID,
|
|
locked: false
|
|
};
|
|
const permissionEngine = createPermissionEngine();
|
|
const permissionHandlers = createPermissionHttpHandlers(permissionEngine, actorRef);
|
|
let projectFetches = 0;
|
|
const cacheEvents: CacheEvent[] = [];
|
|
|
|
const appFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const path = requestPath(input);
|
|
if (path.startsWith('/api/permissions')) {
|
|
const body = init?.body === undefined ? {} : JSON.parse(String(init.body));
|
|
const request = {
|
|
method: init?.method ?? 'POST',
|
|
url: requestUrl(input),
|
|
async json() {
|
|
return body;
|
|
}
|
|
};
|
|
const handler = path.endsWith('/batch')
|
|
? permissionHandlers.batch
|
|
: path.endsWith('/what')
|
|
? permissionHandlers.what
|
|
: path.endsWith('/explain')
|
|
? permissionHandlers.explain
|
|
: permissionHandlers.check;
|
|
const response = await handler(request);
|
|
return jsonResponse(response.body, response.status);
|
|
}
|
|
if (path === AUTH_ROUTE_PATHS.CURRENT) return jsonResponse(authCurrent);
|
|
if (path === AUTH_ROUTE_PATHS.CSRF) return jsonResponse({ token: 'csrf-test-token' });
|
|
if (path === AUTH_ROUTE_PATHS.SIGN_OUT) return jsonResponse({ ok: true });
|
|
if (path === HTTP_PROJECT_PATH) {
|
|
projectFetches += 1;
|
|
return jsonResponse({ id: PROJECT_ID, tenantId: TENANT_ID, version: projectFetches });
|
|
}
|
|
return jsonResponse({ error: 'not-found' }, 404);
|
|
}) as typeof fetch;
|
|
|
|
const App = createActiveApp({
|
|
lang: {
|
|
schema: {
|
|
demo: {
|
|
title: { es: 'Demo', en: 'Demo' }
|
|
}
|
|
},
|
|
defaultLocale: 'es'
|
|
},
|
|
logger: {
|
|
level: LogLevel.TRACE,
|
|
transports: [
|
|
{
|
|
name: 'capture',
|
|
write(entry) {
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
]
|
|
},
|
|
storage: {
|
|
adapter: createMemoryAdapter(),
|
|
namespace: 'ecosystem-test'
|
|
},
|
|
http: {
|
|
fetch: appFetch,
|
|
timeout: 0,
|
|
retry: { limit: 0 }
|
|
},
|
|
cache: {
|
|
scopeResolver: (): ResolvedScopeValues => ({
|
|
tenantId: TENANT_ID,
|
|
actorId: ACTOR_ID,
|
|
permissionHash: actorRole,
|
|
locale: App.getLocale()
|
|
}),
|
|
policies: {
|
|
[CACHE_POLICY_INTERACTIVE]: {
|
|
freshFor: 10_000,
|
|
staleFor: 20_000,
|
|
staleIfErrorFor: 30_000,
|
|
gcAfter: 60_000,
|
|
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
|
|
persist: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
const offCacheEvents = App.Cache.on(CACHE_EVENT_ALL, (event) => {
|
|
cacheEvents.push(event);
|
|
});
|
|
|
|
try {
|
|
App.Logger.info(LOG_CATEGORY, LOG_MESSAGE_BOOT);
|
|
expect(entries).toHaveLength(1);
|
|
|
|
expect(App.Lang.t('demo.title')).toBe('Demo');
|
|
App.setLocale('ar');
|
|
expect(App.Frontend.getDir()).toBe('rtl');
|
|
expect(App.Formats.currency.format(1200).length).toBeGreaterThan(0);
|
|
expect(App.Dom.resolve({ base: 'mobile', md: 'desktop' })).toBeDefined();
|
|
|
|
const storageEntry = App.Storage.entry('draft', () => ({ title: 'Atlas' }));
|
|
storageEntry.update((draft) => ({ ...draft, title: 'Atlas Prime' }));
|
|
expect(storageEntry.current.title).toBe('Atlas Prime');
|
|
|
|
const Sium = App.createSiumEngine();
|
|
const validation = await Sium.validate(
|
|
Sium.object({ title: Sium.pipe(Sium.string(), Sium.min(4)) }),
|
|
storageEntry.current
|
|
);
|
|
expect(validation.ok).toBe(true);
|
|
|
|
const Auth = App.createActiveAuth({ initial: authCurrent });
|
|
await Auth.loadCurrent();
|
|
expect(Auth.authenticated).toBe(true);
|
|
|
|
const Sess = App.createActiveSession<DemoUser, undefined, DemoSessionData>({
|
|
storage: { adapter: createMemoryAdapter(), key: 'session' }
|
|
});
|
|
Sess.adoptServer({
|
|
user: { id: ACTOR_ID, email: 'ada@acme.test' },
|
|
data: { tenantId: TENANT_ID, permissionHash: ROLE_ADMIN },
|
|
issuedAt: 1,
|
|
expiresAt: Date.now() + 60_000
|
|
});
|
|
expect(Sess.current?.user?.id).toBe(ACTOR_ID);
|
|
|
|
const Permissions = App.createActivePermissions({
|
|
endpoint: PERM_ENDPOINT,
|
|
scopeKey: () => `${ACTOR_ID}:${actorRole}`
|
|
});
|
|
const decision = await Permissions.check({
|
|
action: PROJECT_ACTION_UPDATE,
|
|
resource,
|
|
context: { risk: { mfa: true } }
|
|
});
|
|
expect(decision.effect).toBe(PERMISSION_EFFECT_ALLOW);
|
|
|
|
actorRole = ROLE_VIEWER;
|
|
Permissions.invalidate();
|
|
const viewerDecision = await Permissions.check({
|
|
action: PROJECT_ACTION_UPDATE,
|
|
resource,
|
|
context: { risk: { mfa: true } }
|
|
});
|
|
expect(viewerDecision.effect).toBe(PERMISSION_EFFECT_NOT_APPLICABLE);
|
|
expect(Permissions.size).toBeGreaterThan(0);
|
|
|
|
actorRole = ROLE_ADMIN;
|
|
Permissions.invalidate();
|
|
|
|
const project = await App.Cache.query<ProjectPayload>({
|
|
key: ['project', PROJECT_ID],
|
|
scope: CACHE_SCOPE_TENANT,
|
|
policy: CACHE_POLICY_INTERACTIVE,
|
|
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
tags: [{ type: 'project', id: PROJECT_ID }],
|
|
fetcher: async () => {
|
|
const response = await App.Http.get(HTTP_PROJECT_PATH, {
|
|
schema: JSON_PASSTHROUGH_SCHEMA
|
|
});
|
|
if (!response.ok) throw new Error('project request failed');
|
|
return response.value as ProjectPayload;
|
|
}
|
|
});
|
|
const cached = await App.Cache.query<ProjectPayload>({
|
|
key: ['project', PROJECT_ID],
|
|
scope: CACHE_SCOPE_TENANT,
|
|
policy: CACHE_POLICY_INTERACTIVE,
|
|
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
tags: [{ type: 'project', id: PROJECT_ID }],
|
|
fetcher: async () => {
|
|
throw new Error('cache miss should not call this fetcher');
|
|
}
|
|
});
|
|
expect(project.version).toBe(1);
|
|
expect(cached.version).toBe(1);
|
|
expect(projectFetches).toBe(1);
|
|
|
|
App.setLocale('es');
|
|
const localizedProject = await App.Cache.query<ProjectPayload>({
|
|
key: ['project', PROJECT_ID],
|
|
scope: CACHE_SCOPE_TENANT,
|
|
policy: CACHE_POLICY_INTERACTIVE,
|
|
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
tags: [{ type: 'project', id: PROJECT_ID }],
|
|
fetcher: async () => {
|
|
const response = await App.Http.get(HTTP_PROJECT_PATH, {
|
|
schema: JSON_PASSTHROUGH_SCHEMA
|
|
});
|
|
if (!response.ok) throw new Error('localized project request failed');
|
|
return response.value as ProjectPayload;
|
|
}
|
|
});
|
|
expect(localizedProject.version).toBe(2);
|
|
expect(projectFetches).toBe(2);
|
|
|
|
let timerRan = false;
|
|
App.Timers.schedule(TIMER_KEY, 0, () => {
|
|
timerRan = true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(timerRan).toBe(true);
|
|
|
|
const Connections = App.createActiveConnections();
|
|
const transport = createMockTransport();
|
|
const Updates = Connections.createConnection(CONNECTION_NAME, {
|
|
transport,
|
|
heartbeat: false,
|
|
reconnect: false
|
|
});
|
|
const connected = await Updates.connect();
|
|
expect(connected.ok).toBe(true);
|
|
await Updates.send('project.updated', { id: PROJECT_ID });
|
|
expect(transport.sentMessages()).toHaveLength(1);
|
|
|
|
await Auth.signOut();
|
|
expect(Auth.authenticated).toBe(false);
|
|
expect(Permissions.size).toBe(0);
|
|
expect(cacheEvents.some((event) => event.type === CACHE_EVENT_INVALIDATE)).toBe(true);
|
|
} finally {
|
|
offCacheEvents();
|
|
App.dispose();
|
|
permissionEngine.dispose();
|
|
}
|
|
});
|
|
|
|
it('bridges late-created sessions to existing App connection registries', async () => {
|
|
const App = createActiveApp({
|
|
logger: { level: LogLevel.NONE, transports: [] }
|
|
});
|
|
|
|
try {
|
|
const Connections = App.createActiveConnections();
|
|
const transport = createMockTransport();
|
|
const Updates = Connections.createConnection(CONNECTION_NAME, {
|
|
transport,
|
|
heartbeat: false,
|
|
reconnect: false,
|
|
session: { enabled: true }
|
|
});
|
|
|
|
await Updates.connect();
|
|
expect(Updates.state).toBe(CONNECTION_STATE_OPEN);
|
|
|
|
const Sess = App.createActiveSession<DemoUser>();
|
|
Sess.adoptServer({
|
|
user: { id: ACTOR_ID, email: 'ada@acme.test' },
|
|
issuedAt: 1,
|
|
expiresAt: Date.now() + 60_000
|
|
});
|
|
|
|
await Sess.revoke();
|
|
expect(Updates.state).toBe(CONNECTION_STATE_CLOSED);
|
|
} finally {
|
|
App.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
function createPermissionEngine() {
|
|
const schema = definePermSchema({
|
|
actors: {
|
|
user: {
|
|
attributes: {
|
|
role: 'string',
|
|
tenantIds: 'string[]'
|
|
}
|
|
}
|
|
},
|
|
resources: {
|
|
project: {
|
|
actions: ['update'],
|
|
attributes: {
|
|
tenantId: 'string',
|
|
locked: 'boolean'
|
|
}
|
|
}
|
|
},
|
|
relations: {
|
|
[PROJECT_RELATION_MEMBER]: { from: 'project', to: 'user' }
|
|
},
|
|
context: {
|
|
risk: { mfa: 'boolean' }
|
|
}
|
|
});
|
|
|
|
return createEnginePermissions({
|
|
schema,
|
|
policies: definePolicies(schema, [
|
|
allow(PROJECT_ACTION_UPDATE).when(
|
|
and(
|
|
rel(PROJECT_RELATION_MEMBER).is(actor()),
|
|
attr('actor.role').eq(ROLE_ADMIN),
|
|
attr('project.locked').eq(false),
|
|
attr('context.risk.mfa').eq(true)
|
|
)
|
|
)
|
|
]),
|
|
providers: {
|
|
relations: {
|
|
hasRelation({ relation, resource, subject }) {
|
|
if (relation !== PROJECT_RELATION_MEMBER) return 'unknown';
|
|
expect(subject.id).toBe(ACTOR_ID);
|
|
return Array.isArray(subject.tenantIds) && subject.tenantIds.includes(resource.tenantId);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function createAuthCurrent(): AuthCurrentView {
|
|
return {
|
|
session: {
|
|
status: AUTH_SESSION_STATUSES.AUTHENTICATED,
|
|
actorRef: {
|
|
tenantId: TENANT_ID as AuthTenantId,
|
|
actorId: ACTOR_ID as AuthActorId
|
|
},
|
|
sessionId: 'sess-test' as AuthSessionId,
|
|
aal: AUTH_AAL.MULTI_FACTOR,
|
|
amr: [AUTH_AMR.PASSWORD, AUTH_AMR.TOTP],
|
|
authTime: Date.now(),
|
|
expiresAt: Date.now() + 60_000
|
|
},
|
|
actor: {
|
|
tenantId: TENANT_ID as AuthTenantId,
|
|
actorId: ACTOR_ID as AuthActorId,
|
|
displayName: 'Ada',
|
|
primaryIdentifier: 'ada@acme.test'
|
|
}
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { [HTTP_HEADER_CONTENT_TYPE]: HTTP_CONTENT_TYPE_JSON }
|
|
});
|
|
}
|
|
|
|
function requestUrl(input: RequestInfo | URL): string {
|
|
if (typeof input === 'string') return input;
|
|
if (input instanceof URL) return input.href;
|
|
return input.url;
|
|
}
|
|
|
|
function requestPath(input: RequestInfo | URL): string {
|
|
const url = requestUrl(input);
|
|
if (url.startsWith('http://') || url.startsWith('https://')) return new URL(url).pathname;
|
|
return url.split('?')[0] ?? url;
|
|
}
|