mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add owner-confirmed package secret binding
This commit is contained in:
@@ -26,6 +26,10 @@ import {
|
||||
type PluginPackageLifecycleImpact,
|
||||
type PluginPackageLifecycleReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
type PluginPackageSecretBindingPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-plan';
|
||||
import {
|
||||
pluginPackageInstallRecoveryAction,
|
||||
type PluginPackageInstallActionInput,
|
||||
@@ -35,6 +39,7 @@ import type { PluginPackageInstallProposal } from '@qinglong/runtime-core/plugin
|
||||
|
||||
import { createLocalPluginPackageManagementService } from '@qinglong/local-admin/package-management';
|
||||
import { createLocalPluginPackageLifecycleService } from '@qinglong/local-admin/package-lifecycle';
|
||||
import { createLocalPluginPackageSecretBindingService } from '@qinglong/local-admin/package-secret-binding';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_DISPATCH_LIMIT = 64;
|
||||
@@ -177,6 +182,30 @@ export interface ExecuteLocalPluginPackageLifecycleCommand {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanLocalPluginPackageSecretBindingCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.secret-binding.plan';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly assignments: readonly Readonly<{
|
||||
name: string;
|
||||
secretRef: string | null;
|
||||
}>[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecuteLocalPluginPackageSecretBindingCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.secret-binding.execute';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly plan: PluginPackageSecretBindingPlan;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalPluginPackageCommand =
|
||||
| ProposeLocalPluginPackageCommand
|
||||
| DecideLocalPluginPackageCommand
|
||||
@@ -186,7 +215,9 @@ export type LocalPluginPackageCommand =
|
||||
| ListLocalPluginPackageInstallationsCommand
|
||||
| DispatchLocalPluginPackageCommand
|
||||
| PlanLocalPluginPackageLifecycleCommand
|
||||
| ExecuteLocalPluginPackageLifecycleCommand;
|
||||
| ExecuteLocalPluginPackageLifecycleCommand
|
||||
| PlanLocalPluginPackageSecretBindingCommand
|
||||
| ExecuteLocalPluginPackageSecretBindingCommand;
|
||||
|
||||
export interface LocalPluginPackageCommandRunner {
|
||||
run(
|
||||
@@ -251,6 +282,19 @@ export type LocalPluginPackageCommandResult =
|
||||
status: 'created' | 'existing';
|
||||
approval: ReturnType<typeof approvalSummary>;
|
||||
receipt: ReturnType<typeof lifecycleReceiptSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.secret-binding.plan';
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>;
|
||||
summary: ReturnType<typeof secretBindingPlanSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.secret-binding.execute';
|
||||
status: 'created' | 'existing';
|
||||
bindingDigest: string;
|
||||
generationDigest: string;
|
||||
}>;
|
||||
|
||||
export class LocalPluginPackageCommandConfigurationError extends TypeError {
|
||||
@@ -558,6 +602,65 @@ function normalizeCommand(value: unknown): Readonly<LocalPluginPackageCommand> {
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'plugin-package.secret-binding.plan':
|
||||
exactObject(
|
||||
value.request,
|
||||
['assignments', 'packageName', 'projectId'],
|
||||
'Secret binding plan request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(value.request.projectId) ||
|
||||
typeof value.request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(value.request.packageName) ||
|
||||
!Array.isArray(value.request.assignments) ||
|
||||
value.request.assignments.length < 1 ||
|
||||
value.request.assignments.length > 64
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret binding plan request is invalid',
|
||||
);
|
||||
}
|
||||
for (const assignment of value.request.assignments) {
|
||||
exactObject(assignment, ['name', 'secretRef'], 'Secret assignment');
|
||||
if (
|
||||
typeof assignment.name !== 'string' ||
|
||||
!/^[A-Z_][A-Z0-9_]{0,127}$/.test(assignment.name) ||
|
||||
(assignment.secretRef !== null &&
|
||||
(typeof assignment.secretRef !== 'string' ||
|
||||
Buffer.byteLength(assignment.secretRef, 'utf8') > 512))
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret assignment is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'plugin-package.secret-binding.execute':
|
||||
exactObject(
|
||||
value.request,
|
||||
['auditEventId', 'plan'],
|
||||
'Secret binding execution request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.auditEventId !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||
value.request.auditEventId,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret binding audit event ID is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
normalizePluginPackageSecretBindingPlan(value.request.plan);
|
||||
} catch (error) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret binding plan is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'operation is invalid',
|
||||
@@ -714,8 +817,7 @@ function lifecycleReceiptSummary(
|
||||
disposition: receipt.lifecycle.disposition,
|
||||
capabilityStatus: receipt.capability.status,
|
||||
taskTransitions: receipt.capability.taskTransitions.length,
|
||||
previousActiveVectorDigest:
|
||||
receipt.capability.previousActiveVectorDigest,
|
||||
previousActiveVectorDigest: receipt.capability.previousActiveVectorDigest,
|
||||
currentActiveVectorDigest: receipt.capability.currentActiveVectorDigest,
|
||||
currentToolSnapshotDigest: receipt.capability.currentToolSnapshotDigest,
|
||||
retainedSourceCount: receipt.capability.retainedSourceCount,
|
||||
@@ -724,12 +826,68 @@ function lifecycleReceiptSummary(
|
||||
});
|
||||
}
|
||||
|
||||
function secretBindingPlanSummary(
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
projectId: plan.target.projectId,
|
||||
packageName: plan.target.packageName,
|
||||
installationId: plan.target.installationId,
|
||||
generation: plan.target.generation,
|
||||
generationDigest: plan.target.generationDigest,
|
||||
manifestDigest: plan.target.manifestDigest,
|
||||
assignments: Object.freeze(
|
||||
plan.entries.map((entry) =>
|
||||
Object.freeze({
|
||||
name: entry.name,
|
||||
required: entry.required,
|
||||
bound: entry.secretRef !== null,
|
||||
secretRef: entry.secretRef,
|
||||
}),
|
||||
),
|
||||
),
|
||||
plannedAtMs: plan.plannedAtMs,
|
||||
planDigest: plan.planDigest,
|
||||
});
|
||||
}
|
||||
|
||||
async function execute(
|
||||
command: Readonly<LocalPluginPackageCommand>,
|
||||
database: LocalSqlitePluginPackageManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<Readonly<LocalPluginPackageCommandResult>> {
|
||||
await authenticated.confirm();
|
||||
if (command.operation === 'plugin-package.secret-binding.plan') {
|
||||
const service = createLocalPluginPackageSecretBindingService(
|
||||
database.authority,
|
||||
);
|
||||
const plan = await service.plan({
|
||||
...command.request,
|
||||
principal: authenticated.principal,
|
||||
plannedAtMs: Date.now(),
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
plan,
|
||||
summary: secretBindingPlanSummary(plan),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.secret-binding.execute') {
|
||||
const service = createLocalPluginPackageSecretBindingService(
|
||||
database.authority,
|
||||
);
|
||||
const result = await service.execute({
|
||||
...command.request,
|
||||
principal: authenticated.principal,
|
||||
confirmAuthorization: authenticated.confirm,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.lifecycle.plan') {
|
||||
const service = createLocalPluginPackageLifecycleService({
|
||||
authority: database.authority,
|
||||
|
||||
@@ -82,6 +82,7 @@ const {
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
materializePluginPackageResources,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-materialization');
|
||||
@@ -200,7 +201,7 @@ function packageArtifact(manifest) {
|
||||
]);
|
||||
}
|
||||
|
||||
function actionInput() {
|
||||
function actionInput(secretAware = false) {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
@@ -224,8 +225,10 @@ function actionInput() {
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: ['system.command'],
|
||||
secrets: secretAware ? [{ name: 'TOKEN', required: true }] : [],
|
||||
tools: secretAware
|
||||
? ['secret.use', 'system.command']
|
||||
: ['system.command'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.json'],
|
||||
@@ -507,6 +510,46 @@ async function activatePackageAutomation(databasePath, lock, manifest) {
|
||||
}
|
||||
}
|
||||
|
||||
async function activatePackageOnly(databasePath, projectId, packageName) {
|
||||
const client = new DatabaseSync(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
try {
|
||||
const installs = new LocalSqlitePluginPackageInstallRepository(authority);
|
||||
const queued = await installs.find(projectId, packageName);
|
||||
const lock = await installs.findLock(queued.lockDigest);
|
||||
const staged = transitionPluginPackageInstall(lock, queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: 'secret-binding-stage-package',
|
||||
occurredAtMs: queued.updatedAtMs + 1,
|
||||
stageRef: `stage:${lock.lockDigest}`,
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
});
|
||||
await installs.commit(pluginPackageInstallCommit(queued, staged));
|
||||
const activating = transitionPluginPackageInstall(lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: 'secret-binding-start-package',
|
||||
occurredAtMs: staged.updatedAtMs + 1,
|
||||
});
|
||||
await installs.commit(pluginPackageInstallCommit(staged, activating));
|
||||
const active = transitionPluginPackageInstall(lock, activating, {
|
||||
type: 'activation_committed',
|
||||
mutationId: 'secret-binding-commit-package',
|
||||
occurredAtMs: activating.updatedAtMs + 1,
|
||||
activationRef: `activation:${lock.lockDigest}`,
|
||||
intentDigest: pluginPackageActivationIntentDigest(lock, activating),
|
||||
generation: lock.targetGeneration,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
});
|
||||
await installs.commit(pluginPackageInstallCommit(activating, active));
|
||||
return { active, lock };
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
function publisherTrustRunnerWithOneSnapshotFault() {
|
||||
let injectFault = true;
|
||||
return createLocalPluginPackagePublisherTrustCommandRunner({
|
||||
@@ -1348,6 +1391,277 @@ test('runs the private command-file package lifecycle with replay-safe IDs', asy
|
||||
assertNoSensitiveMaterial(installationList);
|
||||
});
|
||||
|
||||
test('plans and atomically binds a versioned Secret to the current Package generation', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const input = actionInput(true);
|
||||
const actionRef = 'proposal:secret-aware-cli-monitor-v1';
|
||||
const approvalRequestId = 'approval-secret-aware-cli-monitor-v1';
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.propose',
|
||||
{
|
||||
actionRef,
|
||||
approvalRequestId,
|
||||
proposalAuditEventId: '21000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '21000000-0000-4000-8000-000000000002',
|
||||
actionInput: input,
|
||||
},
|
||||
'secret-01-propose',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.decide',
|
||||
{
|
||||
actionRef,
|
||||
approvalRequestId,
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-secret-aware-cli-monitor-v1',
|
||||
auditEventId: '21000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
},
|
||||
'secret-02-decide',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.consume',
|
||||
{
|
||||
actionRef,
|
||||
approvalRequestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-secret-aware-cli-monitor-v1',
|
||||
dispatchId: 'dispatch-secret-aware-cli-monitor-v1',
|
||||
auditEventId: '21000000-0000-4000-8000-000000000004',
|
||||
},
|
||||
'secret-03-consume',
|
||||
),
|
||||
);
|
||||
const dispatched = await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.dispatch',
|
||||
{ limit: 1 },
|
||||
'secret-04-dispatch',
|
||||
),
|
||||
);
|
||||
assert.equal(dispatched.summary.succeeded, 1);
|
||||
const { active } = await activatePackageOnly(
|
||||
value.databasePath,
|
||||
'default',
|
||||
'cli-monitor',
|
||||
);
|
||||
const secretRef = createSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'runtime-token',
|
||||
version: 1,
|
||||
});
|
||||
await assert.rejects(
|
||||
runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.plan',
|
||||
{
|
||||
projectId: 'default',
|
||||
packageName: 'cli-monitor',
|
||||
assignments: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'runtime-token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
'secret-05-missing-version',
|
||||
),
|
||||
),
|
||||
/version is unavailable/,
|
||||
);
|
||||
const secretDatabase = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
secretDatabase
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalSecretEnvelopes" (
|
||||
"project_id", "secret_name", "version", "mutation_id",
|
||||
"key_id", "algorithm", "nonce", "ciphertext", "auth_tag",
|
||||
"created_at_ms"
|
||||
) VALUES (?, ?, 1, ?, ?, 'aes-256-gcm', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
'default',
|
||||
'runtime-token',
|
||||
'secret-aware-fixture-v1',
|
||||
'fixture-key-v1',
|
||||
Buffer.alloc(12, 1),
|
||||
Buffer.from('ciphertext'),
|
||||
Buffer.alloc(16, 2),
|
||||
Date.now(),
|
||||
);
|
||||
} finally {
|
||||
secretDatabase.close();
|
||||
}
|
||||
|
||||
const planResult = await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.plan',
|
||||
{
|
||||
projectId: 'default',
|
||||
packageName: 'cli-monitor',
|
||||
assignments: [{ name: 'TOKEN', secretRef }],
|
||||
},
|
||||
'secret-05-plan',
|
||||
),
|
||||
);
|
||||
assert.equal(planResult.summary.installationId, active.installationId);
|
||||
assert.equal(planResult.summary.generation, 1);
|
||||
assert.deepEqual(planResult.summary.assignments, [
|
||||
{ name: 'TOKEN', required: true, bound: true, secretRef },
|
||||
]);
|
||||
assertNoSensitiveMaterial(planResult);
|
||||
|
||||
const revokeDatabase = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
revokeDatabase
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'owner-user', 2, 'revoked', NULL,
|
||||
'secret-binding-revoke-owner', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(Date.now());
|
||||
} finally {
|
||||
revokeDatabase.close();
|
||||
}
|
||||
const executePath = commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.execute',
|
||||
{
|
||||
plan: planResult.plan,
|
||||
auditEventId: '21000000-0000-4000-8000-000000000005',
|
||||
},
|
||||
'secret-06-execute',
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalPluginPackageCommandFile(executePath),
|
||||
/denies Secret management|Project policy changed/,
|
||||
);
|
||||
const restoreDatabase = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
assert.equal(
|
||||
restoreDatabase
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageSecretBindings"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
restoreDatabase
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'plugin_package.secret.bind'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
restoreDatabase
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'owner-user', 3, 'active', 'owner',
|
||||
'secret-binding-restore-owner', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(Date.now());
|
||||
} finally {
|
||||
restoreDatabase.close();
|
||||
}
|
||||
|
||||
const created = await runLocalPluginPackageCommandFile(executePath);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(
|
||||
created.generationDigest,
|
||||
planResult.plan.target.generationDigest,
|
||||
);
|
||||
assert.match(created.bindingDigest, /^[0-9a-f]{64}$/);
|
||||
assertNoSensitiveMaterial(created);
|
||||
const replay = await runLocalPluginPackageCommandFile(executePath);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.bindingDigest, created.bindingDigest);
|
||||
|
||||
await assert.rejects(
|
||||
runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.plan',
|
||||
{
|
||||
projectId: 'default',
|
||||
packageName: 'cli-monitor',
|
||||
assignments: [{ name: 'TOKEN', secretRef }],
|
||||
},
|
||||
'secret-07-rebind-current-generation',
|
||||
),
|
||||
),
|
||||
/rebind requires a new generation/,
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.execute',
|
||||
{
|
||||
plan: planResult.plan,
|
||||
auditEventId: '21000000-0000-4000-8000-000000000006',
|
||||
},
|
||||
'secret-08-different-audit',
|
||||
),
|
||||
),
|
||||
/another audit identity/,
|
||||
);
|
||||
|
||||
const inspection = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
inspection
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageSecretBindings"`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
inspection
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'plugin_package.secret.bind'`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
inspection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('denies an authenticated non-owner before package proposal mutation', async (t) => {
|
||||
const value = await fixture(t, false);
|
||||
await assert.rejects(
|
||||
|
||||
Reference in New Issue
Block a user