@a5c-ai/krate 5.0.1-staging.f672fe79b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +29 -0
- package/README.md +183 -0
- package/bin/krate-demo.mjs +23 -0
- package/bin/krate-server.mjs +14 -0
- package/dist/krate-controller-ui.json +2407 -0
- package/dist/krate-lifecycle.json +201 -0
- package/dist/krate-runtime-snapshot.json +2955 -0
- package/dist/krate-summary.json +687 -0
- package/docs/README.md +61 -0
- package/docs/agents/README.md +83 -0
- package/docs/agents/acceptance-test-matrix.md +193 -0
- package/docs/agents/agent-mux-adapter-contract.md +167 -0
- package/docs/agents/agent-mux-source-map.md +310 -0
- package/docs/agents/agent-run-memory-import-spec.md +256 -0
- package/docs/agents/agent-stack-management-spec.md +421 -0
- package/docs/agents/api-contract-spec.md +309 -0
- package/docs/agents/artifacts-writeback-spec.md +145 -0
- package/docs/agents/chart-packaging-spec.md +128 -0
- package/docs/agents/ci-orchestration-spec.md +140 -0
- package/docs/agents/context-assembly-spec.md +219 -0
- package/docs/agents/controller-reconciliation-spec.md +255 -0
- package/docs/agents/crd-schema-spec.md +315 -0
- package/docs/agents/decision-log-open-questions.md +169 -0
- package/docs/agents/developer-implementation-checklist.md +329 -0
- package/docs/agents/dispatching-design.md +262 -0
- package/docs/agents/glossary.md +66 -0
- package/docs/agents/implementation-blueprint.md +324 -0
- package/docs/agents/implementation-rollout-slices.md +251 -0
- package/docs/agents/memory-context-integration-spec.md +194 -0
- package/docs/agents/memory-ontology-schema-spec.md +253 -0
- package/docs/agents/memory-operations-runbook.md +121 -0
- package/docs/agents/mvp-vertical-slice-spec.md +146 -0
- package/docs/agents/observability-audit-spec.md +265 -0
- package/docs/agents/operator-runbook.md +174 -0
- package/docs/agents/org-memory-api-payload-examples.md +333 -0
- package/docs/agents/org-memory-controller-sequence-spec.md +181 -0
- package/docs/agents/org-memory-e2e-fixture-plan.md +161 -0
- package/docs/agents/org-memory-ui-implementation-map.md +114 -0
- package/docs/agents/org-memory-vertical-slice-spec.md +168 -0
- package/docs/agents/org-resource-model-delta-spec.md +111 -0
- package/docs/agents/org-route-resource-model-spec.md +183 -0
- package/docs/agents/org-scoping-namespace-spec.md +114 -0
- package/docs/agents/rbac-secrets-management-spec.md +406 -0
- package/docs/agents/repository-page-integration-spec.md +255 -0
- package/docs/agents/resource-contract-examples.md +808 -0
- package/docs/agents/resource-relationship-map.md +190 -0
- package/docs/agents/security-threat-model.md +188 -0
- package/docs/agents/shared-memory-company-brain-spec.md +358 -0
- package/docs/agents/storage-migration-spec.md +168 -0
- package/docs/agents/subagent-orchestration-spec.md +152 -0
- package/docs/agents/system-overview.md +88 -0
- package/docs/agents/tools-mcp-skills-spec.md +189 -0
- package/docs/agents/traceability-matrix.md +79 -0
- package/docs/agents/ui-flow-spec.md +211 -0
- package/docs/agents/ui-ux-system-spec.md +426 -0
- package/docs/agents/workspace-lifecycle-spec.md +166 -0
- package/docs/architecture-spec.md +78 -0
- package/docs/components/control-plane.md +78 -0
- package/docs/components/data-plane.md +69 -0
- package/docs/components/hooks-events.md +67 -0
- package/docs/components/identity-rbac-policy.md +73 -0
- package/docs/components/kubevela-oam.md +70 -0
- package/docs/components/operations-publishing.md +81 -0
- package/docs/components/runners-ci.md +66 -0
- package/docs/components/web-ui.md +94 -0
- package/docs/external/README.md +47 -0
- package/docs/external/bidirectional-sync-design.md +134 -0
- package/docs/external/cicd-interface.md +64 -0
- package/docs/external/external-backend-controllers.md +170 -0
- package/docs/external/external-backend-crds.md +234 -0
- package/docs/external/external-backend-ui-spec.md +151 -0
- package/docs/external/external-backend-ux-flows.md +115 -0
- package/docs/external/external-object-mapping.md +125 -0
- package/docs/external/git-forge-interface.md +68 -0
- package/docs/external/github-integration-design.md +151 -0
- package/docs/external/issue-tracking-interface.md +66 -0
- package/docs/external/provider-capability-manifests.md +204 -0
- package/docs/external/provider-catalog.md +139 -0
- package/docs/external/provider-rollout-testing.md +78 -0
- package/docs/external/research-results.md +48 -0
- package/docs/external/security-auth-permissions.md +81 -0
- package/docs/external/sync-state-machines.md +108 -0
- package/docs/external/unified-external-backend-model.md +107 -0
- package/docs/external/user-facing-changes.md +67 -0
- package/docs/gaps.md +161 -0
- package/docs/install.md +94 -0
- package/docs/krate-design.md +334 -0
- package/docs/local-minikube.md +55 -0
- package/docs/ontology/README.md +32 -0
- package/docs/ontology/bounded-contexts.md +29 -0
- package/docs/ontology/events-and-hooks.md +32 -0
- package/docs/ontology/oam-kubevela.md +32 -0
- package/docs/ontology/operations-and-release.md +25 -0
- package/docs/ontology/personas-and-actors.md +32 -0
- package/docs/ontology/policies-and-invariants.md +33 -0
- package/docs/ontology/problem-space.md +30 -0
- package/docs/ontology/resource-contracts.md +40 -0
- package/docs/ontology/resource-taxonomy.md +42 -0
- package/docs/ontology/runners-and-ci.md +29 -0
- package/docs/ontology/solution-space.md +24 -0
- package/docs/ontology/storage-and-data-boundaries.md +29 -0
- package/docs/ontology/validation-matrix.md +24 -0
- package/docs/ontology/web-ui-excellent-flows.md +32 -0
- package/docs/ontology/workflows.md +39 -0
- package/docs/ontology/world.md +35 -0
- package/docs/product-requirements.md +62 -0
- package/docs/roadmap-mvp.md +87 -0
- package/docs/system-requirements.md +90 -0
- package/docs/tests/README.md +53 -0
- package/docs/tests/agent-qa-plan.md +63 -0
- package/docs/tests/browser-ui-tests.md +62 -0
- package/docs/tests/ci-quality-gates.md +48 -0
- package/docs/tests/coverage-model.md +64 -0
- package/docs/tests/e2e-scenario-tests.md +53 -0
- package/docs/tests/fixtures-test-data.md +63 -0
- package/docs/tests/observability-reliability-tests.md +54 -0
- package/docs/tests/product-test-matrix.md +145 -0
- package/docs/tests/qa-adoption-roadmap.md +130 -0
- package/docs/tests/qa-automation-plan.md +101 -0
- package/docs/tests/security-compliance-tests.md +57 -0
- package/docs/tests/test-framework-tools.md +88 -0
- package/docs/tests/test-suite-layout.md +121 -0
- package/docs/tests/unit-integration-tests.md +48 -0
- package/docs/todo-kyverno +714 -0
- package/docs/user-stories.md +78 -0
- package/examples/minikube-demo.yaml +190 -0
- package/examples/oam-application.yaml +23 -0
- package/examples/policy-kyverno-pr-title.yaml +18 -0
- package/package.json +63 -0
- package/scripts/build.mjs +29 -0
- package/scripts/setup-minikube.mjs +65 -0
- package/scripts/smoke.mjs +37 -0
- package/scripts/validate-doc-coverage.mjs +152 -0
- package/scripts/validate-package.mjs +93 -0
- package/scripts/validate-ui.mjs +207 -0
- package/src/agent-approval-controller.js +123 -0
- package/src/agent-context-bundles.js +242 -0
- package/src/agent-dispatch-controller.js +86 -0
- package/src/agent-mux-client.js +280 -0
- package/src/agent-permission-review.js +162 -0
- package/src/agent-stack-controller.js +296 -0
- package/src/agent-trigger-controller.js +108 -0
- package/src/api-controller.js +206 -0
- package/src/argocd-gitops.js +43 -0
- package/src/auth.js +265 -0
- package/src/component-catalog.js +41 -0
- package/src/control-plane.js +136 -0
- package/src/controller-client.js +38 -0
- package/src/controller-ui.js +538 -0
- package/src/data-plane.js +178 -0
- package/src/gitea-backend.js +95 -0
- package/src/handoff.js +98 -0
- package/src/hooks-events.js +63 -0
- package/src/http-server.js +151 -0
- package/src/identity-policy.js +86 -0
- package/src/index.js +30 -0
- package/src/kubernetes-controller.js +812 -0
- package/src/kubernetes-resource-gateway.js +48 -0
- package/src/operations.js +112 -0
- package/src/resource-model.js +203 -0
- package/src/runners-ci.js +48 -0
- package/src/runtime.js +196 -0
- package/src/web-ui.js +40 -0
- package/tests/agent-approval-controller.test.js +173 -0
- package/tests/agent-context-bundles.test.js +278 -0
- package/tests/agent-dispatch-controller.test.js +176 -0
- package/tests/agent-mux-client.test.js +204 -0
- package/tests/agent-permission-review.test.js +209 -0
- package/tests/agent-resources.test.js +212 -0
- package/tests/agent-stack-controller.test.js +221 -0
- package/tests/agent-trigger-controller.test.js +211 -0
- package/tests/deployment.test.js +395 -0
- package/tests/e2e/lifecycle.test.js +117 -0
- package/tests/krate.test.js +727 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { ControlPlane, GiteaGitService, GiteaRepositoryStore, RunnerScheduler, WebhookBus, createAdmissionPolicy, createAuthProviderConfig, buildAuthorizationRedirect, exchangeOAuthCodeForProfile, profileFromDelegatedHeaders, registerLoginProfile, createSessionCookie, parseSessionCookie, createInviteResource, createTeamResource, identityBackendSyncPlan, mapLoginProfileToKrateIdentity, createKrateApiController, createControllerUiModel, createKrateComponentCatalog, createKrateHandoffSummary, createKrateLifecycleSnapshot, createKrateMvpDemo, createPolicyRolloutModel, createResource, createKrateKubernetesReconciler, identityAccessReconciliationPlan, createKubernetesResourceClient, createKubernetesResourceGateway, createGiteaBackend, createGiteaRepositoryHosting, createKrateGitOpsPlan, listResourceDefinitions, mapOidcIdentity, resourceSchemaForKind, resourceToYaml, runSmokeAssertions } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const platform = mapOidcIdentity({ subject: 'platform', email: 'platform@example.com', groups: ['krate:platform-engineers'] });
|
|
6
|
+
const developer = mapOidcIdentity({ subject: 'dev', email: 'dev@example.com', groups: ['krate:developers'] });
|
|
7
|
+
const repoAdmin = mapOidcIdentity({ subject: 'admin', email: 'admin@example.com', groups: ['krate:repo-admins'] });
|
|
8
|
+
|
|
9
|
+
function setOptionalEnv(name, value) {
|
|
10
|
+
if (value === undefined) delete process.env[name];
|
|
11
|
+
else process.env[name] = value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
test('Gitea backend maps Git hosting integrations to API-shaped calls', async () => {
|
|
16
|
+
const calls = [];
|
|
17
|
+
const backend = createGiteaBackend({ baseUrl: 'https://gitea.example.test', token: 'secret', fetchImpl: async (url, options) => {
|
|
18
|
+
calls.push({ url, options });
|
|
19
|
+
return { ok: true, status: 200, async json() { return { ok: true, url, method: options.method }; } };
|
|
20
|
+
} });
|
|
21
|
+
|
|
22
|
+
await backend.createOrganization({ name: 'krate', fullName: 'Krate' });
|
|
23
|
+
await backend.createRepository({ owner: 'krate', name: 'app', private: true, defaultBranch: 'main' });
|
|
24
|
+
await backend.addUserSshKey({ title: 'alice', key: 'ssh-ed25519 USER' });
|
|
25
|
+
await backend.addDeployKey({ owner: 'krate', repo: 'app', title: 'argocd', key: 'ssh-ed25519 AAAA' });
|
|
26
|
+
await backend.addCollaborator({ owner: 'krate', repo: 'app', username: 'alice', permission: 'write' });
|
|
27
|
+
await backend.createTeam({ org: 'krate', name: 'maintainers', permission: 'admin' });
|
|
28
|
+
await backend.createIssue({ owner: 'krate', repo: 'app', title: 'Bug' });
|
|
29
|
+
await backend.createPullRequest({ owner: 'krate', repo: 'app', title: 'Change', head: 'feature', base: 'main' });
|
|
30
|
+
await backend.protectBranch({ owner: 'krate', repo: 'app', branch: 'main', statusChecks: ['ci/test'] });
|
|
31
|
+
await backend.createWebhook({ owner: 'krate', repo: 'app', url: 'https://hooks.example.test/krate', secret: 'hook-secret' });
|
|
32
|
+
|
|
33
|
+
assert.equal(backend.role, 'gitea-backend');
|
|
34
|
+
assert.deepEqual(calls.map((call) => call.options.method), ['POST', 'POST', 'POST', 'POST', 'PUT', 'POST', 'POST', 'POST', 'POST', 'POST']);
|
|
35
|
+
assert.ok(calls[0].url.endsWith('/api/v1/orgs'));
|
|
36
|
+
assert.ok(calls[1].url.endsWith('/api/v1/orgs/krate/repos'));
|
|
37
|
+
assert.ok(calls[2].url.endsWith('/api/v1/user/keys'));
|
|
38
|
+
assert.ok(calls[3].url.endsWith('/api/v1/repos/krate/app/keys'));
|
|
39
|
+
assert.ok(calls[4].url.endsWith('/api/v1/repos/krate/app/collaborators/alice'));
|
|
40
|
+
assert.ok(calls.some((call) => call.url.endsWith('/api/v1/repos/krate/app/issues')));
|
|
41
|
+
assert.ok(calls.some((call) => call.url.endsWith('/api/v1/repos/krate/app/pulls')));
|
|
42
|
+
assert.ok(calls[8].url.endsWith('/api/v1/repos/krate/app/branch_protections'));
|
|
43
|
+
assert.ok(JSON.parse(calls[9].options.body).config.secret === 'hook-secret');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('Argo CD GitOps plan creates an automated Krate Application', () => {
|
|
47
|
+
const plan = createKrateGitOpsPlan({ repoURL: 'https://gitea.example.test/krate/platform-config.git', namespace: 'krate-system' });
|
|
48
|
+
assert.equal(plan.engine, 'argocd');
|
|
49
|
+
assert.equal(plan.application.apiVersion, 'argoproj.io/v1alpha1');
|
|
50
|
+
assert.equal(plan.application.kind, 'Application');
|
|
51
|
+
assert.equal(plan.application.spec.source.repoURL, 'https://gitea.example.test/krate/platform-config.git');
|
|
52
|
+
assert.equal(plan.application.spec.destination.namespace, 'krate-system');
|
|
53
|
+
assert.equal(plan.application.spec.syncPolicy.automated.prune, true);
|
|
54
|
+
assert.equal(plan.application.spec.syncPolicy.automated.selfHeal, true);
|
|
55
|
+
assert.ok(plan.requiredClusterResources.includes('Application.argoproj.io'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('Gitea repository hosting plan includes Argo CD deploy key and webhook integration', () => {
|
|
59
|
+
const hosting = createGiteaRepositoryHosting({ namespace: 'krate-system', repository: 'platform-config' });
|
|
60
|
+
assert.equal(hosting.backend, 'gitea');
|
|
61
|
+
assert.match(hosting.httpUrl, /platform-config\.git$/);
|
|
62
|
+
assert.ok(hosting.integrationPlan.operations.some((step) => step.action === 'addDeployKey' && step.title === 'krate-argocd'));
|
|
63
|
+
assert.ok(hosting.integrationPlan.operations.some((step) => step.action === 'createWebhook'));
|
|
64
|
+
});
|
|
65
|
+
test('resource catalog exposes all ontology kinds and storage boundaries', () => {
|
|
66
|
+
const definitions = listResourceDefinitions();
|
|
67
|
+
assert.equal(definitions.length, 57);
|
|
68
|
+
assert.deepEqual(definitions.filter((definition) => definition.storage === 'etcd').map((definition) => definition.kind), ['Organization', 'OrgNamespaceBinding', 'User', 'Team', 'Invite', 'IdentityMapping', 'AuthProvider', 'Repository', 'SSHKey', 'RepositoryPermission', 'WebhookSubscription', 'RefPolicy', 'BranchProtection', 'PolicyProfile', 'PolicyTemplate', 'PolicyBinding', 'PolicyExceptionRequest', 'View', 'Selector', 'RunnerPool', 'AgentStack', 'AgentSubagent', 'AgentToolProfile', 'AgentMcpServer', 'AgentSkill', 'AgentTriggerRule', 'AgentContextLabel', 'AgentWorkspacePolicy', 'AgentServiceAccount', 'AgentRoleBinding', 'AgentSecretGrant', 'AgentConfigGrant', 'AgentAdapter', 'AgentTransportBinding', 'AgentProviderConfig', 'AgentProject', 'AgentGatewayConfig']);
|
|
69
|
+
assert.deepEqual(definitions.filter((definition) => definition.storage === 'postgres').map((definition) => definition.kind), ['PullRequest', 'Issue', 'Review', 'Pipeline', 'Job', 'WebhookDelivery', 'AgentDispatchRun', 'AgentDispatchAttempt', 'AgentSession', 'AgentContextBundle', 'AgentArtifact', 'AgentApproval', 'AgentWorkspace', 'AgentTriggerExecution', 'AgentCapabilityRequirement', 'WorkItemSessionLink', 'WorkItemWorkspaceLink', 'AgentSessionTranscript', 'AgentSessionAttachment', 'AgentWorkspaceRuntime']);
|
|
70
|
+
assert.equal(resourceSchemaForKind('Organization').plural, 'organizations');
|
|
71
|
+
assert.equal(resourceSchemaForKind('User').plural, 'users');
|
|
72
|
+
assert.equal(resourceSchemaForKind('Team').plural, 'teams');
|
|
73
|
+
assert.equal(resourceSchemaForKind('Invite').plural, 'invites');
|
|
74
|
+
assert.equal(resourceSchemaForKind('IdentityMapping').plural, 'identitymappings');
|
|
75
|
+
assert.equal(resourceSchemaForKind('AuthProvider').plural, 'authproviders');
|
|
76
|
+
assert.equal(resourceSchemaForKind('SSHKey').plural, 'sshkeys');
|
|
77
|
+
assert.equal(resourceSchemaForKind('RepositoryPermission').plural, 'repositorypermissions');
|
|
78
|
+
const schema = resourceSchemaForKind('PullRequest');
|
|
79
|
+
assert.deepEqual(schema.required.spec, ['organizationRef', 'repository', 'title']);
|
|
80
|
+
assert.match(resourceToYaml(createResource('Repository', { name: 'yaml-demo' }, { organizationRef: 'default', visibility: 'private' })), /kind: Repository/);
|
|
81
|
+
assert.match(resourceToYaml(createResource('SSHKey', { name: 'alice-key' }, { organizationRef: 'default', scope: 'user', key: 'ssh-ed25519 AAAA' })), /kind: SSHKey/);
|
|
82
|
+
assert.match(resourceToYaml({ apiVersion: 'core.oam.dev/v1beta1', kind: 'Application', spec: { components: [{ name: 'web', properties: { image: 'krate/mvp-model:0.1.0' }, traits: [{ type: 'scaler', properties: { replicas: 1 } }] }] } }), /components:\n - name: web\n properties:\n image: krate\/mvp-model:0.1.0\n traits:\n - type: scaler/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
test('Krate auth resources map sign-in, teams, invites, and repository identities', () => {
|
|
88
|
+
const config = createAuthProviderConfig({
|
|
89
|
+
KRATE_AUTH_GITHUB_ENABLED: 'true',
|
|
90
|
+
KRATE_AUTH_GITHUB_CLIENT_ID: 'gh-client',
|
|
91
|
+
KRATE_AUTH_GITHUB_CLIENT_SECRET: 'secret',
|
|
92
|
+
KRATE_AUTH_SSO_ENABLED: 'true',
|
|
93
|
+
KRATE_AUTH_SSO_PROVIDER_NAME: 'Company SSO',
|
|
94
|
+
KRATE_AUTH_SSO_CLIENT_ID: 'sso-client',
|
|
95
|
+
KRATE_AUTH_SSO_CLIENT_SECRET: 'secret',
|
|
96
|
+
KRATE_AUTH_SSO_AUTHORIZATION_URL: 'https://idp.example.test/authorize',
|
|
97
|
+
KRATE_AUTH_SSO_TOKEN_URL: 'https://idp.example.test/token',
|
|
98
|
+
KRATE_AUTH_SSO_USERINFO_URL: 'https://idp.example.test/userinfo',
|
|
99
|
+
KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true'
|
|
100
|
+
});
|
|
101
|
+
const redirect = buildAuthorizationRedirect({ provider: config.providers.sso, requestUrl: 'https://krate.example.test/login', state: 'state' });
|
|
102
|
+
const mapped = mapLoginProfileToKrateIdentity({ provider: 'sso', subject: 'user-1', email: 'alice@example.com', displayName: 'Alice', username: 'alice', teams: ['maintainers'], admin: true, namespace: 'krate-test' });
|
|
103
|
+
const team = createTeamResource({ name: 'maintainers', members: ['alice'], repositoryGrants: [{ repository: 'app', permission: 'admin' }], namespace: 'krate-test' });
|
|
104
|
+
const invite = createInviteResource({ email: 'bob@example.com', teams: ['maintainers'], namespace: 'krate-test' });
|
|
105
|
+
const plan = identityBackendSyncPlan({ users: [mapped.user], teams: [team], invites: [invite], mappings: [mapped.mapping], permissions: [createResource('RepositoryPermission', { name: 'app-alice', namespace: 'krate-test' }, { organizationRef: 'default', repository: 'app', subject: 'alice', subjectKind: 'user', permission: 'admin' })] });
|
|
106
|
+
|
|
107
|
+
assert.equal(config.providers.github.enabled, true);
|
|
108
|
+
assert.equal(config.providers.sso.label, 'Company SSO');
|
|
109
|
+
assert.equal(config.delegatedIdentity.enabled, true);
|
|
110
|
+
assert.ok(redirect.url.includes('client_id=sso-client'));
|
|
111
|
+
assert.equal(mapped.user.kind, 'User');
|
|
112
|
+
assert.equal(mapped.mapping.spec.repositoryIdentity.username, 'alice');
|
|
113
|
+
assert.equal(team.kind, 'Team');
|
|
114
|
+
assert.equal(invite.kind, 'Invite');
|
|
115
|
+
assert.deepEqual(plan.mappings.map((entry) => entry.action), ['link-identity']);
|
|
116
|
+
assert.deepEqual(plan.permissions.map((entry) => entry.action), ['sync-repository-permission']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('session cookies parse into the signed-in Krate user', () => {
|
|
120
|
+
const config = createAuthProviderConfig({ KRATE_AUTH_COOKIE_NAME: 'krate_session' });
|
|
121
|
+
const cookie = createSessionCookie(config, { provider: 'sso', subject: 'alice-subject', username: 'alice' });
|
|
122
|
+
const cookieValue = cookie.match(/krate_session=([^;]+)/)?.[1];
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(parseSessionCookie(config, cookieValue), {
|
|
125
|
+
cookieName: 'krate_session',
|
|
126
|
+
provider: 'sso',
|
|
127
|
+
subject: 'alice-subject',
|
|
128
|
+
user: 'alice'
|
|
129
|
+
});
|
|
130
|
+
assert.equal(parseSessionCookie(config, 'not-json'), null);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('OAuth callbacks and delegated identity auto-register Krate users and mappings', async () => {
|
|
134
|
+
const config = createAuthProviderConfig({
|
|
135
|
+
KRATE_AUTH_GITHUB_ENABLED: 'true',
|
|
136
|
+
KRATE_AUTH_GITHUB_CLIENT_ID: 'gh-client',
|
|
137
|
+
KRATE_AUTH_GITHUB_CLIENT_SECRET: 'gh-secret',
|
|
138
|
+
KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true'
|
|
139
|
+
});
|
|
140
|
+
const calls = [];
|
|
141
|
+
const profile = await exchangeOAuthCodeForProfile({
|
|
142
|
+
provider: config.providers.github,
|
|
143
|
+
code: 'oauth-code',
|
|
144
|
+
requestUrl: 'https://krate.example.test/api/auth/callback/github',
|
|
145
|
+
fetchImpl: async (url, options = {}) => {
|
|
146
|
+
calls.push({ url, options });
|
|
147
|
+
if (String(url).includes('access_token')) return { ok: true, status: 200, async json() { return { access_token: 'token' }; } };
|
|
148
|
+
return { ok: true, status: 200, async json() { return { id: 42, login: 'alice', email: 'alice@example.com', name: 'Alice' }; } };
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const applied = [];
|
|
152
|
+
const registration = await registerLoginProfile({
|
|
153
|
+
namespace: 'krate-test',
|
|
154
|
+
profile,
|
|
155
|
+
controller: { async applyResource(resource) { applied.push(resource); return { operation: 'apply', resource }; } }
|
|
156
|
+
});
|
|
157
|
+
const delegated = profileFromDelegatedHeaders({ 'x-forwarded-user': 'bob', 'x-forwarded-email': 'bob@example.com', 'x-forwarded-groups': 'krate:repo-admins,team:release' }, config);
|
|
158
|
+
|
|
159
|
+
assert.equal(profile.username, 'alice');
|
|
160
|
+
assert.equal(calls.length, 2);
|
|
161
|
+
assert.deepEqual(applied.map((resource) => resource.kind), ['User', 'IdentityMapping']);
|
|
162
|
+
assert.equal(registration.mapping.spec.repositoryIdentity.username, 'alice');
|
|
163
|
+
assert.equal(delegated.admin, true);
|
|
164
|
+
assert.equal(delegated.email, 'bob@example.com');
|
|
165
|
+
assert.equal(delegated.delegatedIdentitySource, 'proxy-header');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
test('OAuth callback route completes fake SSO exchange and registers Krate identity', async () => {
|
|
170
|
+
const previous = {
|
|
171
|
+
enabled: process.env.KRATE_AUTH_SSO_ENABLED,
|
|
172
|
+
providerName: process.env.KRATE_AUTH_SSO_PROVIDER_NAME,
|
|
173
|
+
clientId: process.env.KRATE_AUTH_SSO_CLIENT_ID,
|
|
174
|
+
clientSecret: process.env.KRATE_AUTH_SSO_CLIENT_SECRET,
|
|
175
|
+
authorizationUrl: process.env.KRATE_AUTH_SSO_AUTHORIZATION_URL,
|
|
176
|
+
tokenUrl: process.env.KRATE_AUTH_SSO_TOKEN_URL,
|
|
177
|
+
userInfoUrl: process.env.KRATE_AUTH_SSO_USERINFO_URL,
|
|
178
|
+
scopes: process.env.KRATE_AUTH_SSO_SCOPES,
|
|
179
|
+
namespace: process.env.KRATE_NAMESPACE
|
|
180
|
+
};
|
|
181
|
+
Object.assign(process.env, {
|
|
182
|
+
KRATE_AUTH_SSO_ENABLED: 'true',
|
|
183
|
+
KRATE_AUTH_SSO_PROVIDER_NAME: 'Test Workspace SSO',
|
|
184
|
+
KRATE_AUTH_SSO_CLIENT_ID: 'fake-client',
|
|
185
|
+
KRATE_AUTH_SSO_CLIENT_SECRET: 'fake-secret',
|
|
186
|
+
KRATE_AUTH_SSO_AUTHORIZATION_URL: 'https://idp.example.test/authorize',
|
|
187
|
+
KRATE_AUTH_SSO_TOKEN_URL: 'https://idp.example.test/token',
|
|
188
|
+
KRATE_AUTH_SSO_USERINFO_URL: 'https://idp.example.test/userinfo',
|
|
189
|
+
KRATE_AUTH_SSO_SCOPES: 'openid profile email groups',
|
|
190
|
+
KRATE_NAMESPACE: 'krate-test'
|
|
191
|
+
});
|
|
192
|
+
const applied = [];
|
|
193
|
+
const fetchCalls = [];
|
|
194
|
+
const controller = { async applyResource(resource) { applied.push(resource); return { operation: 'apply', resource }; } };
|
|
195
|
+
const fetchImpl = async (url, options = {}) => {
|
|
196
|
+
fetchCalls.push({ url: String(url), options });
|
|
197
|
+
if (String(url).endsWith('/token')) {
|
|
198
|
+
const body = new URLSearchParams(String(options.body));
|
|
199
|
+
assert.equal(body.get('code'), 'fake-code');
|
|
200
|
+
assert.equal(body.get('client_id'), 'fake-client');
|
|
201
|
+
assert.equal(body.get('client_secret'), 'fake-secret');
|
|
202
|
+
assert.equal(body.get('redirect_uri'), 'https://krate.example.test/api/auth/callback/sso');
|
|
203
|
+
return { ok: true, status: 200, async json() { return { access_token: 'fake-access-token' }; } };
|
|
204
|
+
}
|
|
205
|
+
assert.equal(options.headers.Authorization, 'Bearer fake-access-token');
|
|
206
|
+
return { ok: true, status: 200, async json() { return { sub: 'user-123', email: 'route@example.test', preferred_username: 'route-alice', name: 'Route Alice', groups: ['krate:repo-admins', 'team:platform'] }; } };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const { createOAuthCallbackHandler } = await import(`../../web/app/api/auth/callback/[provider]/route.js?test=${Date.now()}`);
|
|
211
|
+
const handler = createOAuthCallbackHandler({ controller, fetchImpl });
|
|
212
|
+
const response = await handler(new Request('https://krate.example.test/api/auth/callback/sso?code=fake-code&state=state'), { params: { provider: 'sso' } });
|
|
213
|
+
|
|
214
|
+
assert.equal(response.status, 302);
|
|
215
|
+
assert.equal(response.headers.get('location'), '/people');
|
|
216
|
+
assert.equal(response.headers.get('x-krate-user'), 'route-alice');
|
|
217
|
+
assert.match(response.headers.get('set-cookie'), /krate_session=/);
|
|
218
|
+
assert.deepEqual(fetchCalls.map((call) => call.url), ['https://idp.example.test/token', 'https://idp.example.test/userinfo']);
|
|
219
|
+
assert.deepEqual(applied.map((resource) => resource.kind), ['User', 'IdentityMapping']);
|
|
220
|
+
assert.equal(applied[0].metadata.name, 'route-alice');
|
|
221
|
+
assert.equal(applied[0].spec.admin, true);
|
|
222
|
+
assert.equal(applied[1].spec.provider, 'sso');
|
|
223
|
+
assert.equal(applied[1].spec.repositoryIdentity.username, 'route-alice');
|
|
224
|
+
} finally {
|
|
225
|
+
setOptionalEnv('KRATE_AUTH_SSO_ENABLED', previous.enabled);
|
|
226
|
+
setOptionalEnv('KRATE_AUTH_SSO_PROVIDER_NAME', previous.providerName);
|
|
227
|
+
setOptionalEnv('KRATE_AUTH_SSO_CLIENT_ID', previous.clientId);
|
|
228
|
+
setOptionalEnv('KRATE_AUTH_SSO_CLIENT_SECRET', previous.clientSecret);
|
|
229
|
+
setOptionalEnv('KRATE_AUTH_SSO_AUTHORIZATION_URL', previous.authorizationUrl);
|
|
230
|
+
setOptionalEnv('KRATE_AUTH_SSO_TOKEN_URL', previous.tokenUrl);
|
|
231
|
+
setOptionalEnv('KRATE_AUTH_SSO_USERINFO_URL', previous.userInfoUrl);
|
|
232
|
+
setOptionalEnv('KRATE_AUTH_SSO_SCOPES', previous.scopes);
|
|
233
|
+
setOptionalEnv('KRATE_NAMESPACE', previous.namespace);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('Delegated identity supports localhost development fallback without proxy headers', () => {
|
|
238
|
+
const config = createAuthProviderConfig({
|
|
239
|
+
KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true',
|
|
240
|
+
KRATE_AUTH_DELEGATED_LOCAL_USER: 'Dev Alice',
|
|
241
|
+
KRATE_AUTH_DELEGATED_LOCAL_EMAIL: 'alice@example.test',
|
|
242
|
+
KRATE_AUTH_DELEGATED_LOCAL_GROUPS: 'krate:repo-admins,team:local'
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const profile = profileFromDelegatedHeaders({}, config, { requestUrl: 'http://localhost:3000/api/auth/delegated' });
|
|
246
|
+
|
|
247
|
+
assert.equal(profile.username, 'dev-alice');
|
|
248
|
+
assert.equal(profile.email, 'alice@example.test');
|
|
249
|
+
assert.equal(profile.admin, true);
|
|
250
|
+
assert.equal(profile.delegatedIdentitySource, 'local-development');
|
|
251
|
+
assert.deepEqual(profile.groups, ['krate:repo-admins', 'team:local']);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('Delegated identity localhost fallback stays disabled for production unless explicitly enabled', () => {
|
|
255
|
+
const config = createAuthProviderConfig({ KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true', NODE_ENV: 'production' });
|
|
256
|
+
|
|
257
|
+
assert.equal(config.delegatedIdentity.localDevelopment.enabled, false);
|
|
258
|
+
assert.throws(
|
|
259
|
+
() => profileFromDelegatedHeaders({}, config, { requestUrl: 'https://krate.example.test/api/auth/delegated' }),
|
|
260
|
+
/Delegated identity header x-forwarded-user is missing/
|
|
261
|
+
);
|
|
262
|
+
assert.throws(
|
|
263
|
+
() => profileFromDelegatedHeaders({}, config, { requestUrl: 'http://localhost:3000/api/auth/delegated' }),
|
|
264
|
+
/Delegated identity header x-forwarded-user is missing/
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const explicitConfig = createAuthProviderConfig({
|
|
268
|
+
KRATE_AUTH_DELEGATED_IDENTITY_ENABLED: 'true',
|
|
269
|
+
KRATE_AUTH_DELEGATED_LOCAL_DEVELOPMENT: 'true',
|
|
270
|
+
NODE_ENV: 'production'
|
|
271
|
+
});
|
|
272
|
+
const profile = profileFromDelegatedHeaders({}, explicitConfig, { requestUrl: 'http://localhost:3000/api/auth/delegated' });
|
|
273
|
+
assert.equal(profile.username, 'local-developer');
|
|
274
|
+
assert.equal(profile.delegatedIdentitySource, 'local-development');
|
|
275
|
+
const boundAddressProfile = profileFromDelegatedHeaders({}, explicitConfig, { requestUrl: 'http://0.0.0.0:8080/api/auth/delegated' });
|
|
276
|
+
assert.equal(boundAddressProfile.username, 'local-developer');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('Delegated identity route redirects localhost fallback even when Kubernetes registration is unavailable', async () => {
|
|
280
|
+
const previous = {
|
|
281
|
+
delegated: process.env.KRATE_AUTH_DELEGATED_IDENTITY_ENABLED,
|
|
282
|
+
kubectl: process.env.KRATE_KUBECTL,
|
|
283
|
+
timeout: process.env.KRATE_KUBECTL_TIMEOUT_MS
|
|
284
|
+
};
|
|
285
|
+
process.env.KRATE_AUTH_DELEGATED_IDENTITY_ENABLED = 'true';
|
|
286
|
+
process.env.KRATE_KUBECTL = 'krate-missing-kubectl';
|
|
287
|
+
process.env.KRATE_KUBECTL_TIMEOUT_MS = '50';
|
|
288
|
+
try {
|
|
289
|
+
const { GET } = await import(`../../web/app/api/auth/delegated/route.js?test=${Date.now()}`);
|
|
290
|
+
const response = await GET(new Request('http://localhost:3000/api/auth/delegated?user=Route%20Alice&email=route@example.test&groups=krate:developers'));
|
|
291
|
+
|
|
292
|
+
assert.equal(response.status, 302);
|
|
293
|
+
assert.equal(response.headers.get('location'), '/people');
|
|
294
|
+
assert.equal(response.headers.get('x-krate-user'), 'route-alice');
|
|
295
|
+
assert.match(response.headers.get('set-cookie'), /krate_session=/);
|
|
296
|
+
} finally {
|
|
297
|
+
setOptionalEnv('KRATE_AUTH_DELEGATED_IDENTITY_ENABLED', previous.delegated);
|
|
298
|
+
setOptionalEnv('KRATE_KUBECTL', previous.kubectl);
|
|
299
|
+
setOptionalEnv('KRATE_KUBECTL_TIMEOUT_MS', previous.timeout);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('Krate delivery resources surface through controller UI model', () => {
|
|
304
|
+
const model = createControllerUiModel({
|
|
305
|
+
source: 'kubernetes',
|
|
306
|
+
namespace: 'krate-system',
|
|
307
|
+
generatedAt: 'test-time',
|
|
308
|
+
kubectl: { available: true, context: 'kind-krate', errors: [] },
|
|
309
|
+
crds: [{ spec: { group: 'core.oam.dev', names: { plural: 'applications' } } }],
|
|
310
|
+
resources: {
|
|
311
|
+
Organization: [{ apiVersion: 'krate.a5c.ai/v1alpha1', kind: 'Organization', metadata: { name: 'default', namespace: 'krate-system' }, spec: { slug: 'default', namespaceName: 'krate-org-default', displayName: 'Default org' } }],
|
|
312
|
+
KubeVelaApplication: [{ apiVersion: 'core.oam.dev/v1beta1', kind: 'Application', metadata: { name: 'app', namespace: 'krate-org-default', labels: { 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default', components: [{ name: 'web', type: 'webservice' }], workflow: { steps: [{ name: 'deploy', type: 'deploy' }] } }, status: { status: 'running', appliedResources: [{ apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'krate-org-default', name: 'web' }], workflow: { status: 'succeeded', finished: true, appRevision: 'app-v1', steps: [{ name: 'deploy', type: 'deploy', phase: 'succeeded' }] }, services: [{ name: 'web', namespace: 'krate-system', healthy: true, message: 'Ready:1/1', workloadDefinition: { apiVersion: 'apps/v1', kind: 'Deployment' } }] } }],
|
|
313
|
+
KubeVelaApplicationRevision: [{ metadata: { name: 'app-v1', labels: { 'app.oam.dev/name': 'app', 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default' }, status: { succeeded: true } }],
|
|
314
|
+
KubeVelaComponentDefinition: [{ metadata: { name: 'webservice' } }],
|
|
315
|
+
KubeVelaWorkloadDefinition: [{ metadata: { name: 'deployments.apps' } }],
|
|
316
|
+
KubeVelaTraitDefinition: [{ metadata: { name: 'ingress' } }],
|
|
317
|
+
KubeVelaScopeDefinition: [{ metadata: { name: 'healthscopes.core.oam.dev' } }],
|
|
318
|
+
KubeVelaPolicyDefinition: [],
|
|
319
|
+
KubeVelaPolicy: [{ metadata: { name: 'topology', labels: { 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default', type: 'topology' } }],
|
|
320
|
+
KubeVelaWorkflowStepDefinition: [{ metadata: { name: 'deploy' } }],
|
|
321
|
+
KubeVelaWorkflow: [{ metadata: { name: 'app', labels: { 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default' }, status: { phase: 'running' } }],
|
|
322
|
+
KubeVelaResourceTracker: [{ metadata: { name: 'app-v1-krate-system', labels: { 'app.oam.dev/name': 'app', 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default', type: 'versioned' } }]
|
|
323
|
+
},
|
|
324
|
+
events: [],
|
|
325
|
+
permissions: [],
|
|
326
|
+
storage: {},
|
|
327
|
+
commands: []
|
|
328
|
+
});
|
|
329
|
+
assert.equal(model.delivery.installed, true);
|
|
330
|
+
assert.equal(model.delivery.counts.applications, 1);
|
|
331
|
+
assert.deepEqual(model.delivery.capabilityCatalog.components, ['webservice']);
|
|
332
|
+
assert.deepEqual(model.delivery.capabilityCatalog.workloads, ['deployments.apps']);
|
|
333
|
+
assert.deepEqual(model.delivery.capabilityCatalog.scopes, ['healthscopes.core.oam.dev']);
|
|
334
|
+
assert.equal(model.delivery.specVersion, 'v0.3.0');
|
|
335
|
+
assert.equal(model.delivery.counts.releases, 1);
|
|
336
|
+
assert.equal(model.delivery.counts.managedResources, 1);
|
|
337
|
+
assert.equal(model.delivery.applications[0].healthy, true);
|
|
338
|
+
assert.deepEqual(model.delivery.applications[0].appliedResources.map((resource) => `${resource.kind}/${resource.namespace}/${resource.name}`), ['Deployment/krate-org-default/web']);
|
|
339
|
+
assert.equal(model.delivery.applications[0].workflow.status, 'succeeded');
|
|
340
|
+
assert.deepEqual(model.delivery.runtime.managedResources.map((tracker) => tracker.name), ['app-v1-krate-system']);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('control plane keeps CRD config in etcd and aggregated records in Postgres', () => {
|
|
344
|
+
const controlPlane = new ControlPlane();
|
|
345
|
+
controlPlane.create(createResource('Repository', { name: 'app' }, { organizationRef: 'default', visibility: 'private' }), repoAdmin);
|
|
346
|
+
controlPlane.create(createResource('PullRequest', { name: 'pr-1' }, { organizationRef: 'default', repository: 'app', title: 'Improve platform routing' }), developer);
|
|
347
|
+
const report = controlPlane.storageReport();
|
|
348
|
+
assert.deepEqual(report.etcd, ['Repository']);
|
|
349
|
+
assert.deepEqual(report.postgres, ['PullRequest']);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
test('API controller is an application facade over the Kubernetes resource gateway', async () => {
|
|
354
|
+
const calls = [];
|
|
355
|
+
const repository = createResource('Repository', { name: 'app', namespace: 'krate-test' }, { organizationRef: 'default', visibility: 'internal', defaultBranch: 'main' });
|
|
356
|
+
const resourceGateway = {
|
|
357
|
+
role: 'kubernetes-resource-gateway',
|
|
358
|
+
namespace: 'krate-test',
|
|
359
|
+
resourceDefinitions: listResourceDefinitions(),
|
|
360
|
+
async snapshot() {
|
|
361
|
+
calls.push(['snapshot']);
|
|
362
|
+
return { source: 'kubernetes', namespace: 'krate-test', resources: { Repository: [repository] }, commands: [], events: [], permissions: [], storage: {} };
|
|
363
|
+
},
|
|
364
|
+
async list(kind) {
|
|
365
|
+
calls.push(['list', kind]);
|
|
366
|
+
return { items: [repository] };
|
|
367
|
+
},
|
|
368
|
+
async get(kind, name) {
|
|
369
|
+
calls.push(['get', kind, name]);
|
|
370
|
+
return repository;
|
|
371
|
+
},
|
|
372
|
+
async apply(resource) {
|
|
373
|
+
calls.push(['apply', resource.kind]);
|
|
374
|
+
return { operation: 'apply', resource };
|
|
375
|
+
},
|
|
376
|
+
async delete(kind, name) {
|
|
377
|
+
calls.push(['delete', kind, name]);
|
|
378
|
+
return { operation: 'delete', resource: null };
|
|
379
|
+
},
|
|
380
|
+
async createRepository(input) {
|
|
381
|
+
calls.push(['createRepository', input.name]);
|
|
382
|
+
return { operation: 'apply', command: 'kubectl apply -f -', resource: repository };
|
|
383
|
+
},
|
|
384
|
+
watch(resourcePath) {
|
|
385
|
+
calls.push(['watch', resourcePath]);
|
|
386
|
+
return { command: 'kubectl get repositories.krate.a5c.ai --watch -o json' };
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const controller = createKrateApiController({ resourceGateway });
|
|
391
|
+
const snapshot = await controller.snapshot();
|
|
392
|
+
const repositories = await controller.listRepositoriesForForge();
|
|
393
|
+
const view = await controller.getRepositoryForgeView('app');
|
|
394
|
+
const created = await controller.createRepository({ name: 'app', organizationRef: 'default' });
|
|
395
|
+
|
|
396
|
+
assert.equal(controller.role, 'krate-api-controller');
|
|
397
|
+
assert.equal(snapshot.architecture.apiController.role, 'krate-api-controller');
|
|
398
|
+
assert.ok(snapshot.architecture.apiController.mustNotOwn.includes('Kubernetes reconciliation loops'));
|
|
399
|
+
assert.equal(repositories[0].cloneUrl.endsWith('/app.git'), true);
|
|
400
|
+
assert.equal(view.primaryFlow, 'browse-code-open-pr-review-merge');
|
|
401
|
+
assert.equal(view.sections.some((section) => section.id === 'pull-requests'), true);
|
|
402
|
+
assert.equal(created.repository.actions.settings, '/orgs/default/repositories/app/settings');
|
|
403
|
+
assert.deepEqual(calls.map((call) => call[0]), ['snapshot', 'list', 'get', 'createRepository']);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('Kubernetes resource gateway owns Kubernetes operations while reconciler owns status projection', async () => {
|
|
407
|
+
const repository = createResource('Repository', { name: 'app', namespace: 'krate-test' }, { organizationRef: 'default', visibility: 'internal' });
|
|
408
|
+
const delegated = [];
|
|
409
|
+
const resourceClient = {
|
|
410
|
+
role: 'kubernetes-resource-client',
|
|
411
|
+
namespace: 'krate-test',
|
|
412
|
+
resourceDefinitions: listResourceDefinitions(),
|
|
413
|
+
async snapshot() { delegated.push('snapshot'); return { source: 'kubernetes', namespace: 'krate-test', resources: { Repository: [repository] } }; },
|
|
414
|
+
async listResource(kind) { delegated.push(`list:${kind}`); return { items: [repository] }; },
|
|
415
|
+
async getResource(kind, name) { delegated.push(`get:${kind}:${name}`); return repository; },
|
|
416
|
+
async applyResource(resource) { delegated.push(`apply:${resource.kind}`); return { operation: 'apply', resource }; },
|
|
417
|
+
async deleteResource(kind, name) { delegated.push(`delete:${kind}:${name}`); return { operation: 'delete' }; },
|
|
418
|
+
watchResource(resourcePath) { delegated.push(`watch:${resourcePath}`); return { command: 'kubectl get repositories.krate.a5c.ai --watch -o json' }; }
|
|
419
|
+
};
|
|
420
|
+
const gateway = createKubernetesResourceGateway({ resourceClient });
|
|
421
|
+
const reconciler = createKrateKubernetesReconciler({ namespace: 'krate-test' });
|
|
422
|
+
const applied = await gateway.createRepository({ name: 'app', organizationRef: 'default' });
|
|
423
|
+
const plan = reconciler.reconcileRepository(repository);
|
|
424
|
+
|
|
425
|
+
assert.equal(gateway.role, 'kubernetes-resource-gateway');
|
|
426
|
+
assert.ok(gateway.mustNotOwn.includes('Next.js page flow decisions'));
|
|
427
|
+
assert.equal(applied.resource.kind, 'Repository');
|
|
428
|
+
assert.equal(delegated.includes('apply:Repository'), true);
|
|
429
|
+
const client = createKubernetesResourceClient({ namespace: 'krate-test' });
|
|
430
|
+
assert.equal(client.role, 'kubernetes-resource-client');
|
|
431
|
+
assert.equal(client.mustNotOwn.includes('forge DTO composition'), true);
|
|
432
|
+
assert.equal(reconciler.role, 'krate-kubernetes-reconciler');
|
|
433
|
+
assert.ok(reconciler.mustNotOwn.includes('HTTP routes'));
|
|
434
|
+
assert.equal(plan.kind, 'RepositoryReconciliationPlan');
|
|
435
|
+
assert.equal(plan.syncIntents.some((intent) => intent.target === 'git-data-plane'), true);
|
|
436
|
+
assert.equal(reconciler.describeReconciliationScope().resources.some((resource) => resource.kind === 'Repository'), true);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('identity access reconciler projects users permissions and SSH keys into Krate status', () => {
|
|
440
|
+
const user = createResource('User', { name: 'alice', namespace: 'krate-test' }, { organizationRef: 'default', email: 'alice@example.com', username: 'alice', teams: ['maintainers'], admin: false });
|
|
441
|
+
const disabledUser = createResource('User', { name: 'bob', namespace: 'krate-test' }, { organizationRef: 'default', email: 'bob@example.com', username: 'bob', disabled: true });
|
|
442
|
+
const permission = createResource('RepositoryPermission', { name: 'app-alice', namespace: 'krate-test' }, { organizationRef: 'default', repository: 'app', subject: 'alice', subjectKind: 'user', permission: 'write', revoked: true });
|
|
443
|
+
const sshKey = createResource('SSHKey', { name: 'alice-laptop', namespace: 'krate-test' }, { organizationRef: 'default', owner: 'alice', title: 'laptop', scope: 'user', key: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKrateTest alice@example.com' });
|
|
444
|
+
const reconciler = createKrateKubernetesReconciler({ namespace: 'krate-test' });
|
|
445
|
+
const activePlan = reconciler.reconcileIdentityAccess(user);
|
|
446
|
+
const disabledPlan = identityAccessReconciliationPlan(disabledUser, { namespace: 'krate-test' });
|
|
447
|
+
const permissionPlan = reconciler.reconcileIdentityAccess(permission);
|
|
448
|
+
const sshPlan = reconciler.reconcileIdentityAccess(sshKey);
|
|
449
|
+
const aggregate = reconciler.reconcileIdentityAccessResources({ User: [user, disabledUser], RepositoryPermission: [permission], SSHKey: [sshKey] });
|
|
450
|
+
|
|
451
|
+
assert.equal(activePlan.desiredStatus.phase, 'Active');
|
|
452
|
+
assert.ok(activePlan.desiredStatus.groups.includes('team:maintainers'));
|
|
453
|
+
assert.ok(activePlan.syncIntents.some((intent) => intent.action === 'ensure-repository-user'));
|
|
454
|
+
assert.equal(disabledPlan.desiredStatus.phase, 'Disabled');
|
|
455
|
+
assert.ok(disabledPlan.syncIntents.some((intent) => intent.action === 'suspend-user'));
|
|
456
|
+
assert.equal(permissionPlan.desiredStatus.phase, 'Revoked');
|
|
457
|
+
assert.equal(permissionPlan.syncIntents[0].action, 'revoke-repository-permission');
|
|
458
|
+
assert.match(sshPlan.desiredStatus.fingerprint, /^sha256:[A-Za-z0-9_-]+$/);
|
|
459
|
+
assert.equal(sshPlan.syncIntents[0].action, 'sync-ssh-key');
|
|
460
|
+
assert.deepEqual(aggregate.counts, { User: 2, RepositoryPermission: 1, SSHKey: 1 });
|
|
461
|
+
assert.equal(aggregate.desiredStatuses.filter((status) => status.phase === 'Revoked').length, 1);
|
|
462
|
+
assert.ok(aggregate.syncIntents.some((intent) => intent.action === 'revoke-repository-permission'));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('controller boundary source keeps kubectl out of API facade and UI flow out of reconciler', () => {
|
|
466
|
+
const apiSource = readFileSync('src/api-controller.js', 'utf8');
|
|
467
|
+
const gatewaySource = readFileSync('src/kubernetes-resource-gateway.js', 'utf8');
|
|
468
|
+
const kubernetesSource = readFileSync('src/kubernetes-controller.js', 'utf8');
|
|
469
|
+
|
|
470
|
+
assert.equal(apiSource.includes('node:child_process'), false);
|
|
471
|
+
assert.equal(apiSource.includes('spawnSync'), false);
|
|
472
|
+
assert.equal(apiSource.includes('KRATE_API_CONTROLLER_BOUNDARY'), true);
|
|
473
|
+
assert.equal(gatewaySource.includes('KUBERNETES_RESOURCE_GATEWAY_BOUNDARY'), true);
|
|
474
|
+
assert.equal(kubernetesSource.includes('KRATE_KUBERNETES_RECONCILER_BOUNDARY'), true);
|
|
475
|
+
assert.equal(kubernetesSource.includes('Next.js page flows'), true);
|
|
476
|
+
assert.equal(kubernetesSource.includes('/repositories/${repo}'), false);
|
|
477
|
+
});test('RBAC and admission enforce Kubernetes-style policy while supporting audit mode', () => {
|
|
478
|
+
const controlPlane = new ControlPlane();
|
|
479
|
+
controlPlane.addAdmissionPolicy(createAdmissionPolicy({ name: 'descriptive-pr-title', mode: 'enforce', match: ({ resource }) => resource.kind === 'PullRequest', validate: ({ resource }) => resource.spec.title?.length >= 8, message: 'title too short' }));
|
|
480
|
+
assert.throws(() => controlPlane.create(createResource('PullRequest', { name: 'bad' }, { organizationRef: 'default', repository: 'app', title: 'tiny' }), developer), /title too short/);
|
|
481
|
+
const accepted = controlPlane.create(createResource('PullRequest', { name: 'good' }, { organizationRef: 'default', repository: 'app', title: 'Implement policy preview flow' }), developer);
|
|
482
|
+
assert.equal(accepted.status.storage, 'postgres');
|
|
483
|
+
assert.throws(() => controlPlane.create(createResource('Repository', { name: 'denied' }, { organizationRef: 'default', visibility: 'private' }), developer), /RBAC denied/);
|
|
484
|
+
const policyModel = createPolicyRolloutModel({ name: 'descriptive-pr-title', mode: 'audit' });
|
|
485
|
+
assert.deepEqual(policyModel.rollout, ['preview', 'audit', 'enforce']);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('watch events and audit-mode admission warnings remain inspectable', () => {
|
|
489
|
+
const controlPlane = new ControlPlane();
|
|
490
|
+
const events = [];
|
|
491
|
+
const stop = controlPlane.watch('PullRequest', (event) => events.push(event));
|
|
492
|
+
controlPlane.addAdmissionPolicy(createAdmissionPolicy({ name: 'audit-title', mode: 'audit', match: ({ resource }) => resource.kind === 'PullRequest', validate: ({ resource }) => resource.spec.title?.includes('audit'), message: 'title should mention audit' }));
|
|
493
|
+
const pr = controlPlane.create(createResource('PullRequest', { name: 'audit-pr' }, { organizationRef: 'default', repository: 'app', title: 'Implement warning visibility' }), developer);
|
|
494
|
+
stop();
|
|
495
|
+
assert.equal(pr.status.storage, 'postgres');
|
|
496
|
+
assert.equal(events.length, 1);
|
|
497
|
+
assert.equal(controlPlane.auditLog.at(-1).warnings[0].policy, 'audit-title');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('Gitea data plane keeps receive-pack warm and protected', () => {
|
|
501
|
+
const controlPlane = new ControlPlane();
|
|
502
|
+
const git = new GiteaGitService({ controlPlane, stores: [new GiteaRepositoryStore({ name: 'gitea-primary', receivePackReady: true })] });
|
|
503
|
+
const repository = git.createRepository({ name: 'app' }, repoAdmin);
|
|
504
|
+
controlPlane.create(createResource('BranchProtection', { name: 'main', namespace: 'krate-org-default' }, { organizationRef: 'default', refs: ['refs/heads/main'], requirePullRequest: true }), repoAdmin);
|
|
505
|
+
const route = git.route('app');
|
|
506
|
+
assert.equal(route.backend, 'gitea');
|
|
507
|
+
assert.equal(route.receivePackReady, true);
|
|
508
|
+
assert.equal(repository.spec.gitHosting.backend, 'gitea');
|
|
509
|
+
assert.equal(repository.spec.gitHosting.integrationPlan.backend, 'gitea');
|
|
510
|
+
assert.ok(repository.spec.gitHosting.integrationPlan.operations.some((step) => step.action === 'addDeployKey'));
|
|
511
|
+
assert.throws(() => git.receivePack({ repository: 'app', ref: 'refs/heads/main', newRev: '1'.repeat(40), actor: developer }), /requires pull request/);
|
|
512
|
+
const event = git.receivePack({ repository: 'app', ref: 'refs/heads/main', newRev: '2'.repeat(40), actor: repoAdmin });
|
|
513
|
+
assert.equal(event.backend, 'gitea');
|
|
514
|
+
assert.equal(event.store, 'gitea-primary');
|
|
515
|
+
assert.match(event.remoteUrl, /gitea/);
|
|
516
|
+
controlPlane.create(createResource('RefPolicy', { name: 'deny-internal', namespace: 'krate-org-default' }, { organizationRef: 'default', deny: ['refs/internal/'] }), repoAdmin);
|
|
517
|
+
assert.throws(() => git.receivePack({ repository: 'app', ref: 'refs/internal/secret', newRev: '3'.repeat(40), actor: repoAdmin }), /RefPolicy deny-internal denied/);
|
|
518
|
+
assert.equal(git.recordObject({ repository: 'app', key: 'lfs/abc', size: 128 }).storage, 'object-storage');
|
|
519
|
+
assert.equal(git.enqueueSearchIndex({ repository: 'app', commit: '2'.repeat(40), paths: ['README.md'] }).status, 'queued');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
test('runner scheduler isolates fork PR jobs and supports resume reruns', () => {
|
|
524
|
+
const controlPlane = new ControlPlane();
|
|
525
|
+
const runners = new RunnerScheduler({ controlPlane });
|
|
526
|
+
const pool = runners.createRunnerPool({ name: 'trusted-linux', warmReplicas: 1, maxReplicas: 3 }, platform);
|
|
527
|
+
assert.equal(runners.planReplicas(pool, 9), 3);
|
|
528
|
+
const run = runners.startPipeline({ name: 'pr-2', repository: 'app', ref: 'refs/pull/2/head', actor: developer, fork: true, steps: ['checkout', 'test', 'publish'] }, developer);
|
|
529
|
+
assert.equal(run.jobs[0].spec.serviceAccount.scopes.secrets, false);
|
|
530
|
+
assert.equal(run.jobs[0].spec.serviceAccount.scopes.clusterApi, false);
|
|
531
|
+
const rerun = runners.rerunFromStep(run.pipeline, 'test', developer);
|
|
532
|
+
assert.equal(rerun.pipeline.spec.resumeFrom, 'test');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('webhooks are signed, stored, inspectable, and replayable', () => {
|
|
536
|
+
const controlPlane = new ControlPlane();
|
|
537
|
+
const bus = new WebhookBus({ controlPlane, secret: 'test-secret' });
|
|
538
|
+
bus.subscribe({ name: 'chatops', url: 'https://hooks.example.test', events: ['pullrequest.created'] }, repoAdmin);
|
|
539
|
+
const first = bus.deliver({ subscriptionName: 'chatops', eventType: 'pullrequest.created', payload: { pr: 'pr-1' } }, repoAdmin);
|
|
540
|
+
const failed = bus.deliver({ subscriptionName: 'chatops', eventType: 'pullrequest.created', payload: { pr: 'pr-2' }, response: { status: 503, body: 'downstream unavailable' } }, repoAdmin);
|
|
541
|
+
const replay = bus.replay(first, repoAdmin);
|
|
542
|
+
assert.equal(first.kind, 'WebhookDelivery');
|
|
543
|
+
assert.equal(first.status.phase, 'Delivered');
|
|
544
|
+
assert.equal(bus.inspect(failed).phase, 'Failed');
|
|
545
|
+
assert.equal(bus.inspect(failed).replayable, true);
|
|
546
|
+
assert.notEqual(first.spec.signature, replay.spec.signature);
|
|
547
|
+
assert.equal(controlPlane.list('WebhookDelivery').items.length, 3);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
test('component catalog and lifecycle snapshot cover every implementation area', () => {
|
|
552
|
+
const demo = createKrateMvpDemo();
|
|
553
|
+
demo.smoke = runSmokeAssertions(demo);
|
|
554
|
+
const catalog = createKrateComponentCatalog(demo);
|
|
555
|
+
const snapshot = createKrateLifecycleSnapshot(demo, { packageInfo: { version: 'test' }, generatedAt: 'test-time' });
|
|
556
|
+
assert.equal(catalog.length, 7);
|
|
557
|
+
assert.equal(snapshot.status, 'ready-for-local-development');
|
|
558
|
+
assert.ok(snapshot.components.some((component) => component.id === 'control-plane' && component.implemented));
|
|
559
|
+
assert.ok(snapshot.components.some((component) => component.id === 'runners-ci' && component.resources.includes('Pipeline')));
|
|
560
|
+
assert.ok(snapshot.operations.releaseGates.includes('e2e package lifecycle tests'));
|
|
561
|
+
assert.ok(snapshot.validation.every((assertion) => assertion.passed));
|
|
562
|
+
});
|
|
563
|
+
test('MVP smoke path covers resources, UI, operations, and release gates', () => {
|
|
564
|
+
const demo = createKrateMvpDemo();
|
|
565
|
+
const smoke = runSmokeAssertions(demo);
|
|
566
|
+
assert.equal(smoke.ok, true);
|
|
567
|
+
assert.ok(demo.ui.dashboard.excellentFlows.includes('Open and review a PR'));
|
|
568
|
+
assert.ok(demo.operations.backupPlan.restoreOrder.includes('Postgres'));
|
|
569
|
+
assert.ok(demo.operations.backupPlan.restoreOrder.includes('repository data'));
|
|
570
|
+
assert.ok(demo.operations.installManifests.includes('krate-gitea'));
|
|
571
|
+
assert.ok(demo.operations.installManifests.includes('kind: Application'));
|
|
572
|
+
assert.equal(demo.resources.repository.spec.gitHosting.backend, 'gitea');
|
|
573
|
+
assert.ok(demo.operations.releaseGates.includes('docs and ontology coverage'));
|
|
574
|
+
assert.ok(demo.operations.observability.alerts.includes('runner saturation'));
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
import { execFileSync } from 'node:child_process';
|
|
578
|
+
import { readFileSync } from 'node:fs';
|
|
579
|
+
|
|
580
|
+
test('handoff summary and package metadata expose runnable lifecycle surfaces', () => {
|
|
581
|
+
const packageInfo = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
582
|
+
const demo = createKrateMvpDemo();
|
|
583
|
+
demo.smoke = runSmokeAssertions(demo);
|
|
584
|
+
const summary = createKrateHandoffSummary(demo, { packageInfo, generatedAt: 'test-time' });
|
|
585
|
+
assert.equal(packageInfo.main, './src/index.js');
|
|
586
|
+
assert.equal(packageInfo.bin['krate-demo'], './bin/krate-demo.mjs');
|
|
587
|
+
assert.equal(packageInfo.scripts.demo, 'node bin/krate-demo.mjs');
|
|
588
|
+
assert.equal(summary.entrypoints.cli, './bin/krate-demo.mjs');
|
|
589
|
+
assert.equal(summary.commands.check, 'npm run check');
|
|
590
|
+
assert.ok(summary.docs.includes('docs/architecture-spec.md'));
|
|
591
|
+
assert.ok(summary.releaseGates.includes('docs and ontology coverage'));
|
|
592
|
+
assert.equal(summary.smoke.ok, true);
|
|
593
|
+
assert.equal(summary.generatedAt, 'test-time');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('CLI demo emits machine-readable MVP handoff summary', () => {
|
|
597
|
+
const output = execFileSync(process.execPath, ['bin/krate-demo.mjs', '--json'], { encoding: 'utf8' });
|
|
598
|
+
const summary = JSON.parse(output);
|
|
599
|
+
assert.equal(summary.project, 'Krate');
|
|
600
|
+
assert.equal(summary.entrypoints.library, './src/index.js');
|
|
601
|
+
assert.equal(summary.commands.demo, 'npm run demo');
|
|
602
|
+
assert.equal(summary.smoke.ok, true);
|
|
603
|
+
assert.ok(summary.excellentFlows.includes('Open and review a PR'));
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
import { KrateRuntime, createKrateHttpServer, createKrateRuntime } from '../src/index.js';
|
|
609
|
+
|
|
610
|
+
test('runtime executes PR checks review and merge lifecycle with persisted resources', () => {
|
|
611
|
+
const runtime = createKrateRuntime();
|
|
612
|
+
const created = runtime.createPullRequest({ repository: 'krate-demo', title: 'Ship runnable lifecycle implementation' });
|
|
613
|
+
assert.equal(created.pullRequest.status.phase, 'Open');
|
|
614
|
+
assert.throws(() => runtime.mergePullRequest({ pullRequest: created.pullRequest.metadata.name }), /not mergeable/);
|
|
615
|
+
const checks = runtime.completePipeline({ pipeline: created.pipeline.metadata.name });
|
|
616
|
+
assert.equal(checks.pipeline.status.phase, 'Succeeded');
|
|
617
|
+
const review = runtime.addReview({ pullRequest: created.pullRequest.metadata.name, decision: 'approved' });
|
|
618
|
+
assert.equal(review.status.phase, 'Approved');
|
|
619
|
+
const merged = runtime.mergePullRequest({ pullRequest: created.pullRequest.metadata.name });
|
|
620
|
+
assert.equal(merged.pullRequest.status.phase, 'Merged');
|
|
621
|
+
assert.equal(merged.delivery.kind, 'WebhookDelivery');
|
|
622
|
+
const snapshot = runtime.snapshot();
|
|
623
|
+
assert.equal(snapshot.resources.PullRequest[0].status.phase, 'Merged');
|
|
624
|
+
assert.ok(snapshot.events.some((event) => event.type === 'git.receive-pack' && event.resource.backend === 'gitea'));
|
|
625
|
+
assert.equal(snapshot.resources.Repository[0].spec.gitHosting.backend, 'gitea');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('runtime snapshot export restores durable state and continues lifecycle', () => {
|
|
629
|
+
const runtime = createKrateRuntime();
|
|
630
|
+
const created = runtime.createPullRequest({ repository: 'krate-demo', title: 'Persist runtime lifecycle across restart' });
|
|
631
|
+
runtime.completePipeline({ pipeline: created.pipeline.metadata.name });
|
|
632
|
+
runtime.addReview({ pullRequest: created.pullRequest.metadata.name, decision: 'approved' });
|
|
633
|
+
runtime.mergePullRequest({ pullRequest: created.pullRequest.metadata.name });
|
|
634
|
+
const exported = runtime.exportSnapshot();
|
|
635
|
+
const restored = KrateRuntime.fromSnapshot(exported);
|
|
636
|
+
const restoredSnapshot = restored.snapshot();
|
|
637
|
+
assert.equal(restoredSnapshot.resources.PullRequest[0].status.phase, 'Merged');
|
|
638
|
+
assert.equal(restoredSnapshot.resources.WebhookDelivery.length, runtime.snapshot().resources.WebhookDelivery.length);
|
|
639
|
+
const next = restored.createPullRequest({ repository: 'krate-demo', title: 'Continue after snapshot import' });
|
|
640
|
+
assert.equal(next.pullRequest.metadata.name, 'pr-2');
|
|
641
|
+
assert.equal(restored.exportSnapshot().controlPlane.stores.postgres.some((resource) => resource.kind === 'PullRequest' && resource.metadata.name === 'pr-2'), true);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test('runtime snapshot preserves git object storage and search index data', () => {
|
|
645
|
+
const runtime = createKrateRuntime();
|
|
646
|
+
runtime.git.recordObject({ repository: 'krate-demo', key: 'lfs/blob.bin', size: 512, mediaType: 'application/octet-stream' });
|
|
647
|
+
runtime.git.enqueueSearchIndex({ repository: 'krate-demo', commit: 'a'.repeat(40), paths: ['src/index.js', 'README.md'] });
|
|
648
|
+
|
|
649
|
+
const exported = runtime.exportSnapshot();
|
|
650
|
+
assert.equal(exported.git.backend.type, 'gitea');
|
|
651
|
+
assert.equal(exported.git.integrationPlans['krate-demo'].backend, 'gitea');
|
|
652
|
+
assert.equal(exported.git.stores[0].objects['krate-demo'][0].key, 'lfs/blob.bin');
|
|
653
|
+
assert.deepEqual(exported.git.stores[0].searchIndex['krate-demo'][0].paths, ['src/index.js', 'README.md']);
|
|
654
|
+
|
|
655
|
+
const restored = KrateRuntime.fromSnapshot(exported);
|
|
656
|
+
const restoredGit = restored.exportSnapshot().git;
|
|
657
|
+
assert.equal(restoredGit.stores[0].objects['krate-demo'][0].size, 512);
|
|
658
|
+
assert.equal(restoredGit.stores[0].searchIndex['krate-demo'][0].commit, 'a'.repeat(40));
|
|
659
|
+
const restoredRoute = restored.git.uploadPack({ repository: 'krate-demo' });
|
|
660
|
+
assert.equal(restoredRoute.cacheable, true);
|
|
661
|
+
assert.equal(restoredRoute.backend, 'gitea');
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('HTTP API exposes executable runtime endpoints', async () => {
|
|
665
|
+
const runtime = createKrateRuntime();
|
|
666
|
+
const server = createKrateHttpServer({ runtime });
|
|
667
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
668
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
669
|
+
try {
|
|
670
|
+
const created = await fetchJson(`${base}/api/orgs/default/pullrequests`, { method: 'POST', body: { repository: 'krate-demo', title: 'Exercise HTTP lifecycle runtime' } });
|
|
671
|
+
assert.equal(created.pullRequest.kind, 'PullRequest');
|
|
672
|
+
await fetchJson(`${base}/api/orgs/default/pullrequests/${created.pullRequest.metadata.name}/checks/complete`, { method: 'POST', body: {} });
|
|
673
|
+
await fetchJson(`${base}/api/orgs/default/pullrequests/${created.pullRequest.metadata.name}/reviews`, { method: 'POST', body: { decision: 'approved' } });
|
|
674
|
+
const merged = await fetchJson(`${base}/api/orgs/default/pullrequests/${created.pullRequest.metadata.name}/merge`, { method: 'POST', body: {} });
|
|
675
|
+
assert.equal(merged.pullRequest.status.phase, 'Merged');
|
|
676
|
+
const snapshot = await fetchJson(`${base}/api/orgs/default/snapshot`);
|
|
677
|
+
assert.ok(snapshot.resources.WebhookDelivery.length >= 2);
|
|
678
|
+
const restored = await fetchJson(`${base}/api/orgs/default/snapshot`, { method: 'POST', body: snapshot.export });
|
|
679
|
+
assert.equal(restored.resources.PullRequest[0].status.phase, 'Merged');
|
|
680
|
+
assert.equal(restored.export.controlPlane.stores.postgres.some((resource) => resource.kind === 'PullRequest'), true);
|
|
681
|
+
} finally {
|
|
682
|
+
await new Promise((resolve) => server.close(resolve));
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
test('HTTP API exposes Git object storage and search indexing runtime endpoints', async () => {
|
|
688
|
+
const runtime = createKrateRuntime();
|
|
689
|
+
const server = createKrateHttpServer({ runtime });
|
|
690
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
691
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
692
|
+
try {
|
|
693
|
+
const object = await fetchJson(`${base}/api/orgs/default/repositories/krate-demo/objects`, {
|
|
694
|
+
method: 'POST',
|
|
695
|
+
body: { key: 'lfs/http.bin', size: 1024, mediaType: 'application/octet-stream' }
|
|
696
|
+
});
|
|
697
|
+
assert.equal(object.repository, 'krate-demo');
|
|
698
|
+
assert.equal(object.storage, 'object-storage');
|
|
699
|
+
|
|
700
|
+
const index = await fetchJson(`${base}/api/orgs/default/repositories/krate-demo/search-index`, {
|
|
701
|
+
method: 'POST',
|
|
702
|
+
body: { commit: 'b'.repeat(40), paths: ['src/http-server.js', 'README.md'] }
|
|
703
|
+
});
|
|
704
|
+
assert.equal(index.status, 'queued');
|
|
705
|
+
assert.deepEqual(index.paths, ['src/http-server.js', 'README.md']);
|
|
706
|
+
|
|
707
|
+
const snapshot = await fetchJson(`${base}/api/orgs/default/snapshot`);
|
|
708
|
+
assert.equal(snapshot.export.git.stores[0].objects['krate-demo'][0].key, 'lfs/http.bin');
|
|
709
|
+
assert.equal(snapshot.export.git.stores[0].searchIndex['krate-demo'][0].commit, 'b'.repeat(40));
|
|
710
|
+
} finally {
|
|
711
|
+
await new Promise((resolve) => server.close(resolve));
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
async function fetchJson(url, { method = 'GET', body } = {}) {
|
|
716
|
+
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
|
717
|
+
const value = await response.json();
|
|
718
|
+
if (!response.ok) throw new Error(`${response.status} ${JSON.stringify(value)}`);
|
|
719
|
+
return value;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
|