@learncard/cli 3.5.0 → 3.6.0
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/CHANGELOG.md +61 -0
- package/README.md +230 -2
- package/dist/index.js +4337 -986
- package/examples/branded.network.yaml +20 -0
- package/examples/delegated-service-account.network.yaml +23 -0
- package/examples/minimal.network.yaml +7 -0
- package/examples/self-hosted-signing.network.yaml +10 -0
- package/examples/service-account.network.yaml +13 -0
- package/examples/state-districts.network.yaml +36 -0
- package/package.json +21 -17
- package/src/auth-grant.test.ts +54 -0
- package/src/auth-grant.ts +34 -0
- package/src/clr/validate.test.ts +65 -0
- package/src/clr/validate.ts +242 -0
- package/src/clr.ts +119 -0
- package/src/demo-inbox-refresh.test.ts +737 -0
- package/src/demo-inbox-refresh.ts +804 -0
- package/src/demo-refresh-command.test.ts +57 -0
- package/src/demo-refresh-command.ts +22 -0
- package/src/demo-refresh-ui.test.ts +66 -0
- package/src/demo-refresh-ui.ts +65 -0
- package/src/demo-refresh.test.ts +140 -0
- package/src/demo-refresh.ts +309 -0
- package/src/doctor/checks.test.ts +448 -0
- package/src/doctor/checks.ts +497 -0
- package/src/doctor.test.ts +67 -0
- package/src/doctor.ts +118 -0
- package/src/inbox.test.ts +257 -0
- package/src/inbox.ts +221 -0
- package/src/index.tsx +70 -8
- package/src/init.ts +1 -1
- package/src/open.ts +1 -1
- package/src/org/apply.test.ts +1108 -0
- package/src/org/apply.ts +924 -0
- package/src/org/branding.test.ts +60 -0
- package/src/org/diff.ts +14 -0
- package/src/org/load.ts +50 -0
- package/src/org/schema.test.ts +256 -0
- package/src/org/schema.ts +216 -0
- package/src/org.ts +124 -0
- package/src/project.test.ts +26 -1
- package/src/project.ts +105 -10
- package/src/promote.test.ts +142 -0
- package/src/promote.ts +202 -0
- package/src/refresh.test.ts +86 -0
- package/src/refresh.ts +93 -0
- package/src/send.test.ts +278 -2
- package/src/send.ts +152 -24
- package/src/setup-signing.ts +1 -1
- package/src/status.ts +2 -4
- package/src/whoami.test.ts +67 -0
- package/src/whoami.ts +129 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { applyOrg, type OrgLearnCard } from './apply';
|
|
6
|
+
import { loadProject } from '../project';
|
|
7
|
+
import type { OrgSpec } from './schema';
|
|
8
|
+
import type { AuthGrantWithActAs } from '../auth-grant';
|
|
9
|
+
|
|
10
|
+
const spec: OrgSpec = {
|
|
11
|
+
issuer: {
|
|
12
|
+
profileId: 'scde',
|
|
13
|
+
displayName: 'South Carolina Department of Education',
|
|
14
|
+
signingAuthority: { type: 'learncard-hosted', name: 'scde-clr' },
|
|
15
|
+
},
|
|
16
|
+
profileManager: {
|
|
17
|
+
displayName: 'SC Districts',
|
|
18
|
+
managed: [{ profileId: 'sc-greenville', displayName: 'Greenville County Schools' }],
|
|
19
|
+
},
|
|
20
|
+
serviceAccounts: [
|
|
21
|
+
{
|
|
22
|
+
name: 'ea-clr-issuer',
|
|
23
|
+
scopes: ['inbox:write', 'inbox:read', 'credentials:write', 'credentials:read'],
|
|
24
|
+
expiresAt: '2027-06-30',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
webhooks: [{ url: 'https://clr.example.org/learncard/webhook' }],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const issuerDid = 'did:web:network.learncard.com:users:scde';
|
|
31
|
+
const authorityRecord = {
|
|
32
|
+
name: 'scde-clr',
|
|
33
|
+
endpoint: 'https://sa.example.com/scde-clr',
|
|
34
|
+
did: 'did:web:sa.example.com:scde-clr',
|
|
35
|
+
ownerDid: issuerDid,
|
|
36
|
+
};
|
|
37
|
+
const managerDid = 'did:web:network.learncard.com:manager:m1';
|
|
38
|
+
const managedDid = 'did:web:network.learncard.com:users:sc-greenville';
|
|
39
|
+
|
|
40
|
+
const makeMockCard = (): OrgLearnCard & {
|
|
41
|
+
invoke: { [K in keyof OrgLearnCard['invoke']]: ReturnType<typeof vi.fn> } & {
|
|
42
|
+
updateAuthGrant: ReturnType<typeof vi.fn>;
|
|
43
|
+
};
|
|
44
|
+
} => ({
|
|
45
|
+
id: { did: vi.fn((method?: string) => (method === 'web' ? issuerDid : 'did:key:z6Mk...')) },
|
|
46
|
+
invoke: {
|
|
47
|
+
getProfile: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
createProfile: vi.fn().mockResolvedValue(issuerDid),
|
|
49
|
+
updateProfile: vi.fn().mockResolvedValue(true),
|
|
50
|
+
createProfileManager: vi.fn().mockResolvedValue(managerDid),
|
|
51
|
+
getAuthGrants: vi.fn().mockResolvedValue([]),
|
|
52
|
+
addAuthGrant: vi.fn().mockResolvedValue('grant-1'),
|
|
53
|
+
updateAuthGrant: vi.fn().mockResolvedValue(true),
|
|
54
|
+
getAPITokenForAuthGrant: vi.fn().mockResolvedValue('jwt-token-abc'),
|
|
55
|
+
getRegisteredSigningAuthorities: vi.fn().mockResolvedValue([]),
|
|
56
|
+
registerSigningAuthority: vi.fn().mockResolvedValue(true),
|
|
57
|
+
setPrimaryRegisteredSigningAuthority: vi.fn().mockResolvedValue(true),
|
|
58
|
+
getSigningAuthorities: vi.fn().mockResolvedValue([]),
|
|
59
|
+
createSigningAuthority: vi.fn().mockResolvedValue(authorityRecord),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const makeMockSigner = (registered: Array<Record<string, unknown>> = []) => ({
|
|
64
|
+
invoke: {
|
|
65
|
+
getRegisteredSigningAuthorities: vi.fn().mockResolvedValue(registered),
|
|
66
|
+
getSigningAuthorities: vi.fn().mockResolvedValue([]),
|
|
67
|
+
createSigningAuthority: vi.fn().mockResolvedValue(authorityRecord),
|
|
68
|
+
registerSigningAuthority: vi.fn().mockResolvedValue(true),
|
|
69
|
+
setPrimaryRegisteredSigningAuthority: vi.fn().mockResolvedValue(true),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const primaryDistrictSigner = () =>
|
|
73
|
+
makeMockSigner([
|
|
74
|
+
{
|
|
75
|
+
signingAuthority: { endpoint: authorityRecord.endpoint },
|
|
76
|
+
relationship: { name: 'scde-clr', did: authorityRecord.did, isPrimary: true },
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const makeMockManager = () => ({
|
|
81
|
+
invoke: {
|
|
82
|
+
createManagedProfile: vi.fn().mockResolvedValue(managedDid),
|
|
83
|
+
getManagedProfiles: vi.fn().mockResolvedValue({ hasMore: false, records: [] }),
|
|
84
|
+
getProfileManagerProfile: vi.fn().mockResolvedValue({
|
|
85
|
+
id: 'm1',
|
|
86
|
+
created: '2026-01-01T00:00:00.000Z',
|
|
87
|
+
displayName: 'SC Districts',
|
|
88
|
+
}),
|
|
89
|
+
updateProfileManagerProfile: vi.fn().mockResolvedValue(true),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const withTmpProject = async (
|
|
94
|
+
fn: (project: Awaited<ReturnType<typeof loadProject>>) => Promise<void>
|
|
95
|
+
) => {
|
|
96
|
+
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'lc-org-apply-'));
|
|
97
|
+
try {
|
|
98
|
+
const project = await loadProject(cwd);
|
|
99
|
+
await fn(project);
|
|
100
|
+
} finally {
|
|
101
|
+
await fs.rm(cwd, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const accountSpec: OrgSpec = { issuer: spec.issuer, serviceAccounts: spec.serviceAccounts };
|
|
106
|
+
const matchingGrant = {
|
|
107
|
+
id: 'grant-1',
|
|
108
|
+
name: 'ea-clr-issuer',
|
|
109
|
+
status: 'active',
|
|
110
|
+
scope: 'inbox:write inbox:read credentials:write credentials:read',
|
|
111
|
+
expiresAt: '2027-06-30T00:00:00.000Z',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const selectedSigner = {
|
|
115
|
+
SIGNING_AUTHORITY_NAME: 'scde-clr',
|
|
116
|
+
SIGNING_AUTHORITY_ENDPOINT: authorityRecord.endpoint,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const makeExistingCard = () => {
|
|
120
|
+
const card = makeMockCard();
|
|
121
|
+
card.invoke.getProfile.mockResolvedValue(spec.issuer);
|
|
122
|
+
card.invoke.getRegisteredSigningAuthorities.mockResolvedValue([
|
|
123
|
+
{
|
|
124
|
+
signingAuthority: { endpoint: authorityRecord.endpoint },
|
|
125
|
+
relationship: { name: 'scde-clr', did: authorityRecord.did, isPrimary: true },
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
card.invoke.getAuthGrants.mockResolvedValue([matchingGrant]);
|
|
129
|
+
return card;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
describe('service-account reconciliation', () => {
|
|
133
|
+
it('rejects a malformed grant without an ID before attempting recovery', async () => {
|
|
134
|
+
await withTmpProject(async project => {
|
|
135
|
+
const card = makeExistingCard();
|
|
136
|
+
card.invoke.getAuthGrants.mockResolvedValue([{ ...matchingGrant, id: undefined }]);
|
|
137
|
+
await expect(
|
|
138
|
+
applyOrg(accountSpec, card, project, {
|
|
139
|
+
secretsOut: path.join(path.dirname(project.envPath), 'secrets.env'),
|
|
140
|
+
})
|
|
141
|
+
).rejects.toThrow('grant without an ID');
|
|
142
|
+
expect(card.invoke.getAPITokenForAuthGrant).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it.each([
|
|
147
|
+
{ scope: 'inbox:read' },
|
|
148
|
+
{ expiresAt: '2028-06-30T00:00:00.000Z' },
|
|
149
|
+
{ expiresAt: undefined },
|
|
150
|
+
])('rejects drift without minting or persisting tokens: %j', async drift => {
|
|
151
|
+
await withTmpProject(async project => {
|
|
152
|
+
const card = makeExistingCard();
|
|
153
|
+
card.invoke.getAuthGrants.mockResolvedValue([{ ...matchingGrant, ...drift }]);
|
|
154
|
+
const secretsOut = path.join(path.dirname(project.envPath), 'secrets.env');
|
|
155
|
+
await expect(applyOrg(accountSpec, card, project, { secretsOut })).rejects.toThrow(
|
|
156
|
+
'grant has drifted'
|
|
157
|
+
);
|
|
158
|
+
await expect(applyOrg(accountSpec, card, project)).rejects.toThrow(
|
|
159
|
+
'revoke it (npx @learncard/cli token --revoke grant-1) and re-run org apply with --secrets-out'
|
|
160
|
+
);
|
|
161
|
+
expect(card.invoke.addAuthGrant).not.toHaveBeenCalled();
|
|
162
|
+
expect(card.invoke.getAPITokenForAuthGrant).not.toHaveBeenCalled();
|
|
163
|
+
await expect(fs.stat(secretsOut)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it.each([{ scope: 'inbox:read' }, { expiresAt: undefined }])(
|
|
168
|
+
'reports drift in dry-run without writes: %j',
|
|
169
|
+
async drift => {
|
|
170
|
+
await withTmpProject(async project => {
|
|
171
|
+
const card = makeExistingCard();
|
|
172
|
+
card.invoke.getAuthGrants.mockResolvedValue([{ ...matchingGrant, ...drift }]);
|
|
173
|
+
const result = await applyOrg(accountSpec, card, project, {
|
|
174
|
+
dryRun: true,
|
|
175
|
+
secretsOut: path.join(path.dirname(project.envPath), 'secrets.env'),
|
|
176
|
+
});
|
|
177
|
+
expect(result.changes).toContainEqual(
|
|
178
|
+
expect.objectContaining({
|
|
179
|
+
resource: 'serviceAccount',
|
|
180
|
+
action: 'drifted',
|
|
181
|
+
detail: expect.stringContaining('token --revoke grant-1'),
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
expect(card.invoke.getAPITokenForAuthGrant).not.toHaveBeenCalled();
|
|
185
|
+
expect(card.invoke.addAuthGrant).not.toHaveBeenCalled();
|
|
186
|
+
expect(await fs.readdir(path.dirname(project.envPath))).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
it('compares normalized scopes and equivalent expiry instants', async () => {
|
|
192
|
+
await withTmpProject(async project => {
|
|
193
|
+
Object.assign(project.env, selectedSigner);
|
|
194
|
+
const card = makeExistingCard();
|
|
195
|
+
card.invoke.getAuthGrants.mockResolvedValue([
|
|
196
|
+
{
|
|
197
|
+
...matchingGrant,
|
|
198
|
+
scope: ' credentials:read\tcredentials:write\n inbox:read inbox:write ',
|
|
199
|
+
expiresAt: '2027-06-29T20:00:00-04:00',
|
|
200
|
+
},
|
|
201
|
+
]);
|
|
202
|
+
const result = await applyOrg(accountSpec, card, project);
|
|
203
|
+
expect(result.changes.every(change => change.action === 'unchanged')).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it.each([undefined, null])('treats absent expiry as no expiry: %j', async expiresAt => {
|
|
208
|
+
await withTmpProject(async project => {
|
|
209
|
+
Object.assign(project.env, selectedSigner);
|
|
210
|
+
const card = makeExistingCard();
|
|
211
|
+
card.invoke.getAuthGrants.mockResolvedValue([{ ...matchingGrant, expiresAt }]);
|
|
212
|
+
const withoutExpiry = {
|
|
213
|
+
...accountSpec,
|
|
214
|
+
serviceAccounts: [
|
|
215
|
+
{
|
|
216
|
+
name: matchingGrant.name,
|
|
217
|
+
scopes: matchingGrant.scope.split(' '),
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
const result = await applyOrg(withoutExpiry, card, project);
|
|
222
|
+
expect(result.changes.every(change => change.action === 'unchanged')).toBe(true);
|
|
223
|
+
await expect(applyOrg(withoutExpiry, makeExistingCard(), project)).rejects.toThrow(
|
|
224
|
+
'expiresAt'
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it.each(['', 'OTHER=keep\n', 'OTHER=keep\nEA_CLR_ISSUER=\n'])(
|
|
230
|
+
're-issues a missing or empty token (contents: %j)',
|
|
231
|
+
async contents => {
|
|
232
|
+
await withTmpProject(async project => {
|
|
233
|
+
const card = makeExistingCard();
|
|
234
|
+
const secretsOut = path.join(path.dirname(project.envPath), 'secrets.env');
|
|
235
|
+
if (contents) await fs.writeFile(secretsOut, contents);
|
|
236
|
+
const result = await applyOrg(accountSpec, card, project, { secretsOut });
|
|
237
|
+
expect(card.invoke.getAPITokenForAuthGrant).toHaveBeenCalledWith('grant-1');
|
|
238
|
+
expect(card.invoke.addAuthGrant).not.toHaveBeenCalled();
|
|
239
|
+
expect(result.changes).toContainEqual({
|
|
240
|
+
resource: 'serviceAccount',
|
|
241
|
+
name: matchingGrant.name,
|
|
242
|
+
action: 'updated',
|
|
243
|
+
detail: 'token re-issued',
|
|
244
|
+
});
|
|
245
|
+
expect(result.outputs.serviceAccounts).toEqual([
|
|
246
|
+
{ name: matchingGrant.name, grantId: 'grant-1', created: false },
|
|
247
|
+
]);
|
|
248
|
+
expect(await fs.readFile(secretsOut, 'utf8')).toBe(
|
|
249
|
+
`${contents ? 'OTHER=keep\n' : ''}EA_CLR_ISSUER=jwt-token-abc\n`
|
|
250
|
+
);
|
|
251
|
+
expect((await fs.stat(secretsOut)).mode & 0o777).toBe(0o600);
|
|
252
|
+
card.invoke.getAPITokenForAuthGrant.mockClear();
|
|
253
|
+
const second = await applyOrg(accountSpec, card, project, { secretsOut });
|
|
254
|
+
expect(second.changes.every(change => change.action === 'unchanged')).toBe(true);
|
|
255
|
+
expect(card.invoke.getAPITokenForAuthGrant).not.toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
it('only previews missing-token recovery during dry-run', async () => {
|
|
261
|
+
await withTmpProject(async project => {
|
|
262
|
+
const card = makeExistingCard();
|
|
263
|
+
const result = await applyOrg(accountSpec, card, project, {
|
|
264
|
+
secretsOut: path.join(path.dirname(project.envPath), 'secrets.env'),
|
|
265
|
+
dryRun: true,
|
|
266
|
+
});
|
|
267
|
+
expect(result.changes).toContainEqual(
|
|
268
|
+
expect.objectContaining({
|
|
269
|
+
resource: 'serviceAccount',
|
|
270
|
+
action: 'would-update',
|
|
271
|
+
detail: 'token would be re-issued',
|
|
272
|
+
})
|
|
273
|
+
);
|
|
274
|
+
expect(card.invoke.getAPITokenForAuthGrant).not.toHaveBeenCalled();
|
|
275
|
+
expect(await fs.readdir(path.dirname(project.envPath))).toEqual([]);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('tightens permissions before opening, replaces duplicate keys, and preserves other lines', async () => {
|
|
280
|
+
await withTmpProject(async project => {
|
|
281
|
+
const card = makeExistingCard();
|
|
282
|
+
card.invoke.getAuthGrants.mockResolvedValue([]);
|
|
283
|
+
const secretsOut = path.join(path.dirname(project.envPath), 'secrets.env');
|
|
284
|
+
await fs.writeFile(
|
|
285
|
+
secretsOut,
|
|
286
|
+
'# keep\nOTHER=keep\nEA_CLR_ISSUER=old\nexport EA_CLR_ISSUER=older\n'
|
|
287
|
+
);
|
|
288
|
+
await fs.chmod(secretsOut, 0o644);
|
|
289
|
+
const open = fs.open.bind(fs);
|
|
290
|
+
const spy = vi.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => {
|
|
291
|
+
if (String(file).startsWith(`${secretsOut}.`)) {
|
|
292
|
+
expect((await fs.stat(secretsOut)).mode & 0o777).toBe(0o600);
|
|
293
|
+
expect(flags).toBe('wx');
|
|
294
|
+
expect(mode).toBe(0o600);
|
|
295
|
+
}
|
|
296
|
+
return open(file, flags, mode);
|
|
297
|
+
});
|
|
298
|
+
try {
|
|
299
|
+
await applyOrg(accountSpec, card, project, { secretsOut });
|
|
300
|
+
expect(await fs.readFile(secretsOut, 'utf8')).toBe(
|
|
301
|
+
'# keep\nOTHER=keep\nEA_CLR_ISSUER=jwt-token-abc\n'
|
|
302
|
+
);
|
|
303
|
+
} finally {
|
|
304
|
+
spy.mockRestore();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it.each(['symlink', 'directory', 'dangling symlink'])(
|
|
310
|
+
'rejects a %s secrets path without touching its target',
|
|
311
|
+
async kind => {
|
|
312
|
+
await withTmpProject(async project => {
|
|
313
|
+
const card = makeExistingCard();
|
|
314
|
+
const dir = path.dirname(project.envPath);
|
|
315
|
+
const secretsOut = path.join(dir, 'secrets.env');
|
|
316
|
+
const target = path.join(dir, 'target.env');
|
|
317
|
+
if (kind === 'directory') await fs.mkdir(secretsOut);
|
|
318
|
+
else {
|
|
319
|
+
if (kind === 'symlink')
|
|
320
|
+
await fs.writeFile(target, 'DO_NOT_TOUCH=secret\n', { mode: 0o644 });
|
|
321
|
+
await fs.symlink(target, secretsOut);
|
|
322
|
+
}
|
|
323
|
+
await expect(applyOrg(accountSpec, card, project, { secretsOut })).rejects.toThrow(
|
|
324
|
+
'regular file'
|
|
325
|
+
);
|
|
326
|
+
expect(card.invoke.getAPITokenForAuthGrant).not.toHaveBeenCalled();
|
|
327
|
+
card.invoke.getAuthGrants.mockResolvedValue([]);
|
|
328
|
+
await expect(applyOrg(accountSpec, card, project, { secretsOut })).rejects.toThrow(
|
|
329
|
+
'regular file'
|
|
330
|
+
);
|
|
331
|
+
if (kind === 'symlink') {
|
|
332
|
+
expect(await fs.readFile(target, 'utf8')).toBe('DO_NOT_TOUCH=secret\n');
|
|
333
|
+
expect((await fs.stat(target)).mode & 0o777).toBe(0o644);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
it('closes a failed write and recovers on the next run without creating another grant', async () => {
|
|
340
|
+
await withTmpProject(async project => {
|
|
341
|
+
Object.assign(project.env, selectedSigner);
|
|
342
|
+
const card = makeExistingCard();
|
|
343
|
+
card.invoke.getAuthGrants.mockResolvedValueOnce([]);
|
|
344
|
+
const secretsOut = path.join(path.dirname(project.envPath), 'secrets.env');
|
|
345
|
+
await fs.writeFile(secretsOut, 'OTHER=keep\n');
|
|
346
|
+
const originalOpen = fs.open.bind(fs);
|
|
347
|
+
const close = vi.fn();
|
|
348
|
+
let injected = false;
|
|
349
|
+
const open = vi.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => {
|
|
350
|
+
const handle = await originalOpen(file, flags, mode);
|
|
351
|
+
if (injected || !String(file).startsWith(`${secretsOut}.`)) return handle;
|
|
352
|
+
injected = true;
|
|
353
|
+
const originalWrite = handle.writeFile.bind(handle);
|
|
354
|
+
vi.spyOn(handle, 'writeFile').mockImplementationOnce(async () => {
|
|
355
|
+
await originalWrite('EA_CLR_ISSUER=partial', 'utf8');
|
|
356
|
+
throw new Error('disk full');
|
|
357
|
+
});
|
|
358
|
+
const originalClose = handle.close.bind(handle);
|
|
359
|
+
vi.spyOn(handle, 'close').mockImplementation(async () => {
|
|
360
|
+
close();
|
|
361
|
+
await originalClose();
|
|
362
|
+
});
|
|
363
|
+
return handle;
|
|
364
|
+
});
|
|
365
|
+
try {
|
|
366
|
+
await expect(applyOrg(accountSpec, card, project, { secretsOut })).rejects.toThrow(
|
|
367
|
+
'disk full'
|
|
368
|
+
);
|
|
369
|
+
expect(close).toHaveBeenCalledOnce();
|
|
370
|
+
expect(await fs.readFile(secretsOut, 'utf8')).toBe('OTHER=keep\n');
|
|
371
|
+
expect(await fs.readdir(path.dirname(secretsOut))).toEqual(['secrets.env']);
|
|
372
|
+
} finally {
|
|
373
|
+
open.mockRestore();
|
|
374
|
+
}
|
|
375
|
+
const result = await applyOrg(accountSpec, card, project, { secretsOut });
|
|
376
|
+
expect(card.invoke.addAuthGrant).toHaveBeenCalledOnce();
|
|
377
|
+
expect(card.invoke.getAPITokenForAuthGrant).toHaveBeenCalledTimes(2);
|
|
378
|
+
expect(result.changes).toContainEqual(
|
|
379
|
+
expect.objectContaining({ action: 'updated', detail: 'token re-issued' })
|
|
380
|
+
);
|
|
381
|
+
expect(await fs.readFile(secretsOut, 'utf8')).toBe(
|
|
382
|
+
'OTHER=keep\nEA_CLR_ISSUER=jwt-token-abc\n'
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('applyOrg', () => {
|
|
389
|
+
it('creates every resource on a fresh project and writes the service-account token', async () => {
|
|
390
|
+
await withTmpProject(async project => {
|
|
391
|
+
const card = makeMockCard();
|
|
392
|
+
const manager = makeMockManager();
|
|
393
|
+
const connectAsManager = vi.fn().mockResolvedValue(manager);
|
|
394
|
+
const secretsOut = path.join(path.dirname(project.envPath), 'secrets.env');
|
|
395
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
396
|
+
|
|
397
|
+
const districtSigner = makeMockSigner();
|
|
398
|
+
const connectAsManagedSigner = vi.fn().mockResolvedValue(districtSigner);
|
|
399
|
+
const result = await applyOrg(spec, card, project, {
|
|
400
|
+
secretsOut,
|
|
401
|
+
connectAsManager,
|
|
402
|
+
connectAsManagedSigner,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(card.invoke.createProfile).toHaveBeenCalledWith({
|
|
406
|
+
profileId: 'scde',
|
|
407
|
+
displayName: 'South Carolina Department of Education',
|
|
408
|
+
bio: '',
|
|
409
|
+
shortBio: '',
|
|
410
|
+
});
|
|
411
|
+
expect(card.invoke.createSigningAuthority).toHaveBeenCalledTimes(1);
|
|
412
|
+
expect(card.invoke.registerSigningAuthority).toHaveBeenCalledTimes(1);
|
|
413
|
+
expect(card.invoke.setPrimaryRegisteredSigningAuthority).toHaveBeenCalledTimes(1);
|
|
414
|
+
expect(card.invoke.createProfileManager).toHaveBeenCalledWith({
|
|
415
|
+
displayName: 'SC Districts',
|
|
416
|
+
});
|
|
417
|
+
expect(connectAsManager).toHaveBeenCalledWith(managerDid);
|
|
418
|
+
expect(manager.invoke.createManagedProfile).toHaveBeenCalledWith({
|
|
419
|
+
profileId: 'sc-greenville',
|
|
420
|
+
displayName: 'Greenville County Schools',
|
|
421
|
+
bio: '',
|
|
422
|
+
shortBio: '',
|
|
423
|
+
});
|
|
424
|
+
expect(connectAsManagedSigner).toHaveBeenCalledWith(managedDid);
|
|
425
|
+
expect(districtSigner.invoke.createSigningAuthority).toHaveBeenCalledWith('scde-clr');
|
|
426
|
+
expect(districtSigner.invoke.registerSigningAuthority).toHaveBeenCalledWith(
|
|
427
|
+
authorityRecord.endpoint,
|
|
428
|
+
authorityRecord.name,
|
|
429
|
+
authorityRecord.did
|
|
430
|
+
);
|
|
431
|
+
expect(project.env.SIGNING_AUTHORITY_NAME).toBe('scde-clr');
|
|
432
|
+
expect(card.invoke.addAuthGrant).toHaveBeenCalledWith({
|
|
433
|
+
name: 'ea-clr-issuer',
|
|
434
|
+
scope: 'inbox:write inbox:read credentials:write credentials:read',
|
|
435
|
+
expiresAt: new Date('2027-06-30').toISOString(),
|
|
436
|
+
});
|
|
437
|
+
expect(card.invoke.getAPITokenForAuthGrant).toHaveBeenCalledWith('grant-1');
|
|
438
|
+
|
|
439
|
+
const actions = result.changes.map(c => `${c.resource}:${c.action}`);
|
|
440
|
+
expect(actions).toContain('issuer:created');
|
|
441
|
+
expect(actions).toContain('signingAuthority:created');
|
|
442
|
+
expect(actions).toContain('profileManager:created');
|
|
443
|
+
expect(actions).toContain('managedProfile:created');
|
|
444
|
+
expect(result.changes).toContainEqual({
|
|
445
|
+
resource: 'signingAuthority',
|
|
446
|
+
name: 'sc-greenville/scde-clr',
|
|
447
|
+
action: 'created',
|
|
448
|
+
});
|
|
449
|
+
expect(actions).toContain('serviceAccount:created');
|
|
450
|
+
expect(actions).toContain('webhook:created');
|
|
451
|
+
expect(project.env.WEBHOOK_URL).toBe('https://clr.example.org/learncard/webhook');
|
|
452
|
+
|
|
453
|
+
expect(result.outputs.issuerDid).toBe(issuerDid);
|
|
454
|
+
expect(result.outputs.managerDid).toBe(managerDid);
|
|
455
|
+
expect(result.outputs.managed).toEqual([
|
|
456
|
+
{ profileId: 'sc-greenville', did: managedDid },
|
|
457
|
+
]);
|
|
458
|
+
expect(result.outputs.serviceAccounts).toEqual([
|
|
459
|
+
{ name: 'ea-clr-issuer', grantId: 'grant-1', created: true },
|
|
460
|
+
]);
|
|
461
|
+
|
|
462
|
+
expect(project.env.ORG_PROFILE_MANAGER_DID).toBe(managerDid);
|
|
463
|
+
|
|
464
|
+
const secrets = await fs.readFile(secretsOut, 'utf8');
|
|
465
|
+
expect(secrets).toBe('EA_CLR_ISSUER=jwt-token-abc\n');
|
|
466
|
+
const stat = await fs.stat(secretsOut);
|
|
467
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
468
|
+
|
|
469
|
+
log.mockRestore();
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('reports everything unchanged and makes zero mutating calls on a second identical run', async () => {
|
|
474
|
+
await withTmpProject(async project => {
|
|
475
|
+
Object.assign(project.env, selectedSigner);
|
|
476
|
+
const card = makeMockCard();
|
|
477
|
+
card.invoke.getProfile.mockResolvedValue({
|
|
478
|
+
profileId: 'scde',
|
|
479
|
+
displayName: 'South Carolina Department of Education',
|
|
480
|
+
did: issuerDid,
|
|
481
|
+
});
|
|
482
|
+
card.invoke.getRegisteredSigningAuthorities.mockResolvedValue([
|
|
483
|
+
{
|
|
484
|
+
signingAuthority: { endpoint: authorityRecord.endpoint },
|
|
485
|
+
relationship: { name: 'scde-clr', did: authorityRecord.did, isPrimary: true },
|
|
486
|
+
},
|
|
487
|
+
]);
|
|
488
|
+
const manager = makeMockManager();
|
|
489
|
+
manager.invoke.getManagedProfiles.mockResolvedValue({
|
|
490
|
+
hasMore: false,
|
|
491
|
+
records: [
|
|
492
|
+
{
|
|
493
|
+
profileId: 'sc-greenville',
|
|
494
|
+
displayName: 'Greenville County Schools',
|
|
495
|
+
did: managedDid,
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
});
|
|
499
|
+
card.invoke.getAuthGrants.mockResolvedValue([
|
|
500
|
+
{
|
|
501
|
+
id: 'grant-1',
|
|
502
|
+
name: 'ea-clr-issuer',
|
|
503
|
+
status: 'active',
|
|
504
|
+
scope: spec.serviceAccounts![0]!.scopes.join(' '),
|
|
505
|
+
expiresAt: '2027-06-30T00:00:00.000Z',
|
|
506
|
+
},
|
|
507
|
+
]);
|
|
508
|
+
project.env.ORG_PROFILE_MANAGER_DID = managerDid;
|
|
509
|
+
project.env.WEBHOOK_URL = 'https://clr.example.org/learncard/webhook';
|
|
510
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
511
|
+
|
|
512
|
+
const result = await applyOrg(spec, card, project, {
|
|
513
|
+
connectAsManager: async () => manager,
|
|
514
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
expect(card.invoke.createProfile).not.toHaveBeenCalled();
|
|
518
|
+
expect(card.invoke.updateProfile).not.toHaveBeenCalled();
|
|
519
|
+
expect(card.invoke.createSigningAuthority).not.toHaveBeenCalled();
|
|
520
|
+
expect(card.invoke.registerSigningAuthority).not.toHaveBeenCalled();
|
|
521
|
+
expect(card.invoke.setPrimaryRegisteredSigningAuthority).not.toHaveBeenCalled();
|
|
522
|
+
expect(card.invoke.createProfileManager).not.toHaveBeenCalled();
|
|
523
|
+
expect(manager.invoke.createManagedProfile).not.toHaveBeenCalled();
|
|
524
|
+
expect(card.invoke.addAuthGrant).not.toHaveBeenCalled();
|
|
525
|
+
expect(card.invoke.getAPITokenForAuthGrant).not.toHaveBeenCalled();
|
|
526
|
+
|
|
527
|
+
expect(result.changes.every(c => c.action === 'unchanged')).toBe(true);
|
|
528
|
+
expect(result.outputs.managed).toEqual([
|
|
529
|
+
{ profileId: 'sc-greenville', did: managedDid },
|
|
530
|
+
]);
|
|
531
|
+
expect(result.outputs.serviceAccounts).toEqual([
|
|
532
|
+
{ name: 'ea-clr-issuer', grantId: 'grant-1', created: false },
|
|
533
|
+
]);
|
|
534
|
+
|
|
535
|
+
log.mockRestore();
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('dry-run reports would-create actions and calls no mutating methods', async () => {
|
|
540
|
+
await withTmpProject(async project => {
|
|
541
|
+
const card = makeMockCard();
|
|
542
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
543
|
+
|
|
544
|
+
const connectAsManager = vi.fn();
|
|
545
|
+
|
|
546
|
+
const result = await applyOrg(spec, card, project, { dryRun: true, connectAsManager });
|
|
547
|
+
expect(connectAsManager).not.toHaveBeenCalled();
|
|
548
|
+
|
|
549
|
+
for (const key of [
|
|
550
|
+
'createProfile',
|
|
551
|
+
'updateProfile',
|
|
552
|
+
'createSigningAuthority',
|
|
553
|
+
'registerSigningAuthority',
|
|
554
|
+
'setPrimaryRegisteredSigningAuthority',
|
|
555
|
+
'createProfileManager',
|
|
556
|
+
'addAuthGrant',
|
|
557
|
+
'updateAuthGrant',
|
|
558
|
+
'getAPITokenForAuthGrant',
|
|
559
|
+
] as const) {
|
|
560
|
+
expect(card.invoke[key]).not.toHaveBeenCalled();
|
|
561
|
+
}
|
|
562
|
+
for (const key of ['getRegisteredSigningAuthorities', 'getAuthGrants'] as const) {
|
|
563
|
+
expect(card.invoke[key]).not.toHaveBeenCalled();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const actions = result.changes.map(c => `${c.resource}:${c.action}`);
|
|
567
|
+
expect(actions).toContain('issuer:would-create');
|
|
568
|
+
expect(actions).toContain('signingAuthority:would-create');
|
|
569
|
+
expect(actions).toContain('profileManager:would-create');
|
|
570
|
+
expect(actions).toContain('managedProfile:would-create');
|
|
571
|
+
expect(actions).toContain('serviceAccount:would-create');
|
|
572
|
+
|
|
573
|
+
expect(result.outputs.issuerDid).toBe(issuerDid);
|
|
574
|
+
expect(result.outputs.managerDid).toBeUndefined();
|
|
575
|
+
expect(result.outputs.managed).toEqual([]);
|
|
576
|
+
expect(result.outputs.serviceAccounts).toEqual([]);
|
|
577
|
+
expect(project.env.ORG_PROFILE_MANAGER_DID).toBeUndefined();
|
|
578
|
+
|
|
579
|
+
log.mockRestore();
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('requires --secrets-out before creating a service account', async () => {
|
|
584
|
+
await withTmpProject(async project => {
|
|
585
|
+
const card = makeMockCard();
|
|
586
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
587
|
+
|
|
588
|
+
await expect(
|
|
589
|
+
applyOrg(spec, card, project, {
|
|
590
|
+
connectAsManager: async () => makeMockManager(),
|
|
591
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
592
|
+
})
|
|
593
|
+
).rejects.toThrow('--secrets-out');
|
|
594
|
+
expect(card.invoke.addAuthGrant).not.toHaveBeenCalled();
|
|
595
|
+
|
|
596
|
+
log.mockRestore();
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("joins a new service account's actAs list with a comma when creating the grant", async () => {
|
|
601
|
+
await withTmpProject(async project => {
|
|
602
|
+
const card = makeMockCard();
|
|
603
|
+
const secretsOut = path.join(path.dirname(project.envPath), 'secrets.env');
|
|
604
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
605
|
+
|
|
606
|
+
const specWithActAs: OrgSpec = {
|
|
607
|
+
...spec,
|
|
608
|
+
serviceAccounts: [
|
|
609
|
+
{ ...spec.serviceAccounts![0]!, actAs: ['sc-greenville', 'sc-north'] },
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
await applyOrg(specWithActAs, card, project, {
|
|
614
|
+
secretsOut,
|
|
615
|
+
connectAsManager: async () => makeMockManager(),
|
|
616
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
expect(card.invoke.addAuthGrant).toHaveBeenCalledWith({
|
|
620
|
+
name: 'ea-clr-issuer',
|
|
621
|
+
scope: 'inbox:write inbox:read credentials:write credentials:read',
|
|
622
|
+
expiresAt: new Date('2027-06-30').toISOString(),
|
|
623
|
+
actAs: 'sc-greenville,sc-north',
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
log.mockRestore();
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('passes "*" straight through as actAs when creating the grant', async () => {
|
|
631
|
+
await withTmpProject(async project => {
|
|
632
|
+
const card = makeMockCard();
|
|
633
|
+
const secretsOut = path.join(path.dirname(project.envPath), 'secrets.env');
|
|
634
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
635
|
+
|
|
636
|
+
const specWithActAs: OrgSpec = {
|
|
637
|
+
...spec,
|
|
638
|
+
serviceAccounts: [{ ...spec.serviceAccounts![0]!, actAs: '*' }],
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
await applyOrg(specWithActAs, card, project, {
|
|
642
|
+
secretsOut,
|
|
643
|
+
connectAsManager: async () => makeMockManager(),
|
|
644
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
expect(card.invoke.addAuthGrant).toHaveBeenCalledWith(
|
|
648
|
+
expect.objectContaining({ actAs: '*' })
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
log.mockRestore();
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('fails with a revoke + re-create hint when actAs differs from the existing grant', async () => {
|
|
656
|
+
await withTmpProject(async project => {
|
|
657
|
+
const card = makeMockCard();
|
|
658
|
+
card.invoke.getProfile.mockResolvedValue({
|
|
659
|
+
profileId: 'scde',
|
|
660
|
+
displayName: 'South Carolina Department of Education',
|
|
661
|
+
did: issuerDid,
|
|
662
|
+
});
|
|
663
|
+
card.invoke.getRegisteredSigningAuthorities.mockResolvedValue([
|
|
664
|
+
{
|
|
665
|
+
signingAuthority: { endpoint: authorityRecord.endpoint },
|
|
666
|
+
relationship: { name: 'scde-clr', did: authorityRecord.did, isPrimary: true },
|
|
667
|
+
},
|
|
668
|
+
]);
|
|
669
|
+
const existingGrant: AuthGrantWithActAs = {
|
|
670
|
+
id: 'grant-1',
|
|
671
|
+
name: 'ea-clr-issuer',
|
|
672
|
+
status: 'active',
|
|
673
|
+
scope: matchingGrant.scope,
|
|
674
|
+
expiresAt: matchingGrant.expiresAt,
|
|
675
|
+
actAs: 'sc-greenville',
|
|
676
|
+
};
|
|
677
|
+
card.invoke.getAuthGrants.mockResolvedValue([existingGrant]);
|
|
678
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
679
|
+
|
|
680
|
+
const specWithActAs: OrgSpec = {
|
|
681
|
+
...spec,
|
|
682
|
+
serviceAccounts: [{ ...spec.serviceAccounts![0]!, actAs: '*' }],
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const message =
|
|
686
|
+
'Service account "ea-clr-issuer" grant has drifted (actAs sc-greenville -> any managed profile). These are fixed when the token is minted — revoke it (npx @learncard/cli token --revoke grant-1)';
|
|
687
|
+
|
|
688
|
+
const preview = await applyOrg(specWithActAs, card, project, {
|
|
689
|
+
dryRun: true,
|
|
690
|
+
connectAsManager: async () => makeMockManager(),
|
|
691
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
692
|
+
});
|
|
693
|
+
expect(
|
|
694
|
+
preview.changes.find(
|
|
695
|
+
c => c.resource === 'serviceAccount' && c.name === 'ea-clr-issuer'
|
|
696
|
+
)
|
|
697
|
+
).toMatchObject({ action: 'drifted', detail: expect.stringContaining(message) });
|
|
698
|
+
|
|
699
|
+
await expect(
|
|
700
|
+
applyOrg(specWithActAs, card, project, {
|
|
701
|
+
connectAsManager: async () => makeMockManager(),
|
|
702
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
703
|
+
})
|
|
704
|
+
).rejects.toThrow(message);
|
|
705
|
+
|
|
706
|
+
expect(card.invoke.updateAuthGrant).not.toHaveBeenCalled();
|
|
707
|
+
expect(card.invoke.addAuthGrant).not.toHaveBeenCalled();
|
|
708
|
+
|
|
709
|
+
log.mockRestore();
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('fails when the spec drops actAs from a grant that has it', async () => {
|
|
714
|
+
await withTmpProject(async project => {
|
|
715
|
+
const card = makeMockCard();
|
|
716
|
+
const existingGrant: AuthGrantWithActAs = {
|
|
717
|
+
id: 'grant-1',
|
|
718
|
+
name: 'ea-clr-issuer',
|
|
719
|
+
status: 'active',
|
|
720
|
+
scope: matchingGrant.scope,
|
|
721
|
+
expiresAt: matchingGrant.expiresAt,
|
|
722
|
+
actAs: '*',
|
|
723
|
+
};
|
|
724
|
+
card.invoke.getAuthGrants.mockResolvedValue([existingGrant]);
|
|
725
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
726
|
+
|
|
727
|
+
await expect(
|
|
728
|
+
applyOrg(spec, card, project, {
|
|
729
|
+
connectAsManager: async () => makeMockManager(),
|
|
730
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
731
|
+
})
|
|
732
|
+
).rejects.toThrow('grant has drifted (actAs any managed profile -> no delegation)');
|
|
733
|
+
expect(card.invoke.updateAuthGrant).not.toHaveBeenCalled();
|
|
734
|
+
|
|
735
|
+
log.mockRestore();
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it.each(['sc-greenville,sc-north', 'sc-north,sc-greenville', ' sc-north , sc-greenville '])(
|
|
740
|
+
'leaves an existing grant unchanged when actAs matches the spec as a set: %j',
|
|
741
|
+
async grantActAs => {
|
|
742
|
+
await withTmpProject(async project => {
|
|
743
|
+
const card = makeMockCard();
|
|
744
|
+
const existingGrant: AuthGrantWithActAs = {
|
|
745
|
+
id: 'grant-1',
|
|
746
|
+
name: 'ea-clr-issuer',
|
|
747
|
+
status: 'active',
|
|
748
|
+
scope: matchingGrant.scope,
|
|
749
|
+
expiresAt: matchingGrant.expiresAt,
|
|
750
|
+
actAs: grantActAs,
|
|
751
|
+
};
|
|
752
|
+
card.invoke.getAuthGrants.mockResolvedValue([existingGrant]);
|
|
753
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
754
|
+
|
|
755
|
+
const specWithActAs: OrgSpec = {
|
|
756
|
+
...spec,
|
|
757
|
+
serviceAccounts: [
|
|
758
|
+
{ ...spec.serviceAccounts![0]!, actAs: ['sc-greenville', 'sc-north'] },
|
|
759
|
+
],
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const result = await applyOrg(specWithActAs, card, project, {
|
|
763
|
+
connectAsManager: async () => makeMockManager(),
|
|
764
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
expect(card.invoke.updateAuthGrant).not.toHaveBeenCalled();
|
|
768
|
+
expect(
|
|
769
|
+
result.changes.find(
|
|
770
|
+
c => c.resource === 'serviceAccount' && c.name === 'ea-clr-issuer'
|
|
771
|
+
)
|
|
772
|
+
).toMatchObject({ action: 'unchanged' });
|
|
773
|
+
|
|
774
|
+
log.mockRestore();
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
describe('signing-authority reconciliation', () => {
|
|
781
|
+
const selfHosted = {
|
|
782
|
+
type: 'self-hosted' as const,
|
|
783
|
+
name: 'scde-clr',
|
|
784
|
+
endpoint: authorityRecord.endpoint,
|
|
785
|
+
did: authorityRecord.did,
|
|
786
|
+
};
|
|
787
|
+
const selfHostedSpec: OrgSpec = {
|
|
788
|
+
issuer: { ...spec.issuer, signingAuthority: selfHosted },
|
|
789
|
+
};
|
|
790
|
+
const registration = (did: string, isPrimary = true) => ({
|
|
791
|
+
signingAuthority: { endpoint: authorityRecord.endpoint },
|
|
792
|
+
relationship: { name: 'scde-clr', did, isPrimary },
|
|
793
|
+
});
|
|
794
|
+
const selected = {
|
|
795
|
+
SIGNING_AUTHORITY_NAME: 'scde-clr',
|
|
796
|
+
SIGNING_AUTHORITY_ENDPOINT: authorityRecord.endpoint,
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
it('fails clearly when the registered DID differs from the spec', async () => {
|
|
800
|
+
await withTmpProject(async project => {
|
|
801
|
+
const card = makeExistingCard();
|
|
802
|
+
card.invoke.getRegisteredSigningAuthorities.mockResolvedValue([
|
|
803
|
+
registration('did:web:sa.example.com:rotated'),
|
|
804
|
+
]);
|
|
805
|
+
await expect(applyOrg(selfHostedSpec, card, project)).rejects.toThrow(
|
|
806
|
+
/registered at .* with DID did:web:sa\.example\.com:rotated, but the spec declares/
|
|
807
|
+
);
|
|
808
|
+
expect(card.invoke.registerSigningAuthority).not.toHaveBeenCalled();
|
|
809
|
+
expect(card.invoke.setPrimaryRegisteredSigningAuthority).not.toHaveBeenCalled();
|
|
810
|
+
expect(project.env.SIGNING_AUTHORITY_NAME).toBeUndefined();
|
|
811
|
+
|
|
812
|
+
const preview = await applyOrg(selfHostedSpec, card, project, { dryRun: true });
|
|
813
|
+
expect(preview.changes).toContainEqual(
|
|
814
|
+
expect.objectContaining({ resource: 'signingAuthority', action: 'drifted' })
|
|
815
|
+
);
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it('persists the selection in .env when the registration already matches', async () => {
|
|
820
|
+
await withTmpProject(async project => {
|
|
821
|
+
const card = makeExistingCard();
|
|
822
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
823
|
+
const preview = await applyOrg(selfHostedSpec, card, project, { dryRun: true });
|
|
824
|
+
expect(preview.changes).toContainEqual(
|
|
825
|
+
expect.objectContaining({
|
|
826
|
+
resource: 'signingAuthority',
|
|
827
|
+
action: 'would-update',
|
|
828
|
+
detail: expect.stringContaining('.env'),
|
|
829
|
+
})
|
|
830
|
+
);
|
|
831
|
+
expect(project.env.SIGNING_AUTHORITY_NAME).toBeUndefined();
|
|
832
|
+
|
|
833
|
+
const result = await applyOrg(selfHostedSpec, card, project);
|
|
834
|
+
expect(result.changes).toContainEqual(
|
|
835
|
+
expect.objectContaining({ resource: 'signingAuthority', action: 'updated' })
|
|
836
|
+
);
|
|
837
|
+
expect(project.env).toMatchObject(selected);
|
|
838
|
+
expect(card.invoke.setPrimaryRegisteredSigningAuthority).not.toHaveBeenCalled();
|
|
839
|
+
|
|
840
|
+
const second = await applyOrg(selfHostedSpec, card, project);
|
|
841
|
+
expect(second.changes).toContainEqual({
|
|
842
|
+
resource: 'signingAuthority',
|
|
843
|
+
name: 'scde-clr',
|
|
844
|
+
action: 'unchanged',
|
|
845
|
+
});
|
|
846
|
+
log.mockRestore();
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('persists a hosted signer selection that was removed from .env', async () => {
|
|
851
|
+
await withTmpProject(async project => {
|
|
852
|
+
const card = makeExistingCard();
|
|
853
|
+
const hostedSpec: OrgSpec = { issuer: spec.issuer };
|
|
854
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
855
|
+
const preview = await applyOrg(hostedSpec, card, project, { dryRun: true });
|
|
856
|
+
expect(preview.changes).toContainEqual(
|
|
857
|
+
expect.objectContaining({
|
|
858
|
+
resource: 'signingAuthority',
|
|
859
|
+
action: 'would-update',
|
|
860
|
+
detail: expect.stringContaining('.env'),
|
|
861
|
+
})
|
|
862
|
+
);
|
|
863
|
+
const result = await applyOrg(hostedSpec, card, project);
|
|
864
|
+
expect(result.changes).toContainEqual(
|
|
865
|
+
expect.objectContaining({ resource: 'signingAuthority', action: 'updated' })
|
|
866
|
+
);
|
|
867
|
+
expect(project.env).toMatchObject(selected);
|
|
868
|
+
expect(card.invoke.createSigningAuthority).not.toHaveBeenCalled();
|
|
869
|
+
expect(card.invoke.setPrimaryRegisteredSigningAuthority).not.toHaveBeenCalled();
|
|
870
|
+
const second = await applyOrg(hostedSpec, card, project);
|
|
871
|
+
expect(second.changes).toContainEqual({
|
|
872
|
+
resource: 'signingAuthority',
|
|
873
|
+
name: 'scde-clr',
|
|
874
|
+
action: 'unchanged',
|
|
875
|
+
});
|
|
876
|
+
log.mockRestore();
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it('persists the selection when it has to set the registration primary', async () => {
|
|
881
|
+
await withTmpProject(async project => {
|
|
882
|
+
const card = makeExistingCard();
|
|
883
|
+
card.invoke.getRegisteredSigningAuthorities.mockResolvedValue([
|
|
884
|
+
registration(authorityRecord.did, false),
|
|
885
|
+
]);
|
|
886
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
887
|
+
const result = await applyOrg(selfHostedSpec, card, project);
|
|
888
|
+
expect(card.invoke.setPrimaryRegisteredSigningAuthority).toHaveBeenCalledWith(
|
|
889
|
+
authorityRecord.endpoint,
|
|
890
|
+
'scde-clr'
|
|
891
|
+
);
|
|
892
|
+
expect(result.changes).toContainEqual(
|
|
893
|
+
expect.objectContaining({ action: 'updated', detail: 'set primary' })
|
|
894
|
+
);
|
|
895
|
+
expect(project.env).toMatchObject(selected);
|
|
896
|
+
log.mockRestore();
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
describe('profile-manager reconciliation', () => {
|
|
902
|
+
const managerSpec: OrgSpec = { issuer: spec.issuer, profileManager: spec.profileManager };
|
|
903
|
+
const existingManaged = {
|
|
904
|
+
hasMore: false,
|
|
905
|
+
records: [
|
|
906
|
+
{
|
|
907
|
+
profileId: 'sc-greenville',
|
|
908
|
+
displayName: 'Greenville County Schools',
|
|
909
|
+
did: managedDid,
|
|
910
|
+
},
|
|
911
|
+
],
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
it('renames the manager when the spec displayName changes', async () => {
|
|
915
|
+
await withTmpProject(async project => {
|
|
916
|
+
Object.assign(project.env, selectedSigner);
|
|
917
|
+
const card = makeExistingCard();
|
|
918
|
+
const manager = makeMockManager();
|
|
919
|
+
manager.invoke.getProfileManagerProfile.mockResolvedValue({
|
|
920
|
+
id: 'm1',
|
|
921
|
+
created: '2026-01-01T00:00:00.000Z',
|
|
922
|
+
displayName: 'Old Districts',
|
|
923
|
+
});
|
|
924
|
+
manager.invoke.getManagedProfiles.mockResolvedValue(existingManaged);
|
|
925
|
+
project.env.ORG_PROFILE_MANAGER_DID = managerDid;
|
|
926
|
+
|
|
927
|
+
const preview = await applyOrg(managerSpec, card, project, {
|
|
928
|
+
dryRun: true,
|
|
929
|
+
connectAsManager: async () => manager,
|
|
930
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
931
|
+
});
|
|
932
|
+
expect(preview.changes).toContainEqual(
|
|
933
|
+
expect.objectContaining({
|
|
934
|
+
resource: 'profileManager',
|
|
935
|
+
action: 'would-update',
|
|
936
|
+
detail: 'displayName',
|
|
937
|
+
})
|
|
938
|
+
);
|
|
939
|
+
expect(manager.invoke.updateProfileManagerProfile).not.toHaveBeenCalled();
|
|
940
|
+
|
|
941
|
+
const result = await applyOrg(managerSpec, card, project, {
|
|
942
|
+
connectAsManager: async () => manager,
|
|
943
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
944
|
+
});
|
|
945
|
+
expect(manager.invoke.updateProfileManagerProfile).toHaveBeenCalledWith({
|
|
946
|
+
displayName: 'SC Districts',
|
|
947
|
+
});
|
|
948
|
+
expect(result.changes).toContainEqual(
|
|
949
|
+
expect.objectContaining({
|
|
950
|
+
resource: 'profileManager',
|
|
951
|
+
action: 'updated',
|
|
952
|
+
detail: 'displayName',
|
|
953
|
+
})
|
|
954
|
+
);
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it('previews the hosted signer for a new district under an existing manager', async () => {
|
|
959
|
+
await withTmpProject(async project => {
|
|
960
|
+
Object.assign(project.env, selectedSigner);
|
|
961
|
+
const card = makeExistingCard();
|
|
962
|
+
const manager = makeMockManager();
|
|
963
|
+
project.env.ORG_PROFILE_MANAGER_DID = managerDid;
|
|
964
|
+
const connectAsManagedSigner = vi.fn();
|
|
965
|
+
const preview = await applyOrg(managerSpec, card, project, {
|
|
966
|
+
dryRun: true,
|
|
967
|
+
connectAsManager: async () => manager,
|
|
968
|
+
connectAsManagedSigner,
|
|
969
|
+
});
|
|
970
|
+
expect(preview.changes).toContainEqual({
|
|
971
|
+
resource: 'managedProfile',
|
|
972
|
+
name: 'sc-greenville',
|
|
973
|
+
action: 'would-create',
|
|
974
|
+
});
|
|
975
|
+
expect(preview.changes).toContainEqual({
|
|
976
|
+
resource: 'signingAuthority',
|
|
977
|
+
name: 'sc-greenville/scde-clr',
|
|
978
|
+
action: 'would-create',
|
|
979
|
+
});
|
|
980
|
+
expect(connectAsManagedSigner).not.toHaveBeenCalled();
|
|
981
|
+
expect(manager.invoke.createManagedProfile).not.toHaveBeenCalled();
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it('renames a managed profile when the spec displayName changes', async () => {
|
|
986
|
+
await withTmpProject(async project => {
|
|
987
|
+
const card = makeExistingCard();
|
|
988
|
+
const manager = makeMockManager();
|
|
989
|
+
manager.invoke.getManagedProfiles.mockResolvedValue({
|
|
990
|
+
hasMore: false,
|
|
991
|
+
records: [{ ...existingManaged.records[0], displayName: 'Greenville Schools' }],
|
|
992
|
+
});
|
|
993
|
+
const managedCard = {
|
|
994
|
+
invoke: { getProfile: vi.fn(), updateProfile: vi.fn().mockResolvedValue(true) },
|
|
995
|
+
};
|
|
996
|
+
const connectAsManaged = vi.fn().mockResolvedValue(managedCard);
|
|
997
|
+
project.env.ORG_PROFILE_MANAGER_DID = managerDid;
|
|
998
|
+
|
|
999
|
+
const preview = await applyOrg(managerSpec, card, project, {
|
|
1000
|
+
dryRun: true,
|
|
1001
|
+
connectAsManager: async () => manager,
|
|
1002
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
1003
|
+
connectAsManaged,
|
|
1004
|
+
});
|
|
1005
|
+
expect(preview.changes).toContainEqual(
|
|
1006
|
+
expect.objectContaining({
|
|
1007
|
+
resource: 'managedProfile',
|
|
1008
|
+
action: 'would-update',
|
|
1009
|
+
detail: 'displayName',
|
|
1010
|
+
})
|
|
1011
|
+
);
|
|
1012
|
+
expect(connectAsManaged).not.toHaveBeenCalled();
|
|
1013
|
+
|
|
1014
|
+
const result = await applyOrg(managerSpec, card, project, {
|
|
1015
|
+
connectAsManager: async () => manager,
|
|
1016
|
+
connectAsManagedSigner: async () => primaryDistrictSigner(),
|
|
1017
|
+
connectAsManaged,
|
|
1018
|
+
});
|
|
1019
|
+
expect(connectAsManaged).toHaveBeenCalledWith(managedDid);
|
|
1020
|
+
expect(managedCard.invoke.updateProfile).toHaveBeenCalledWith({
|
|
1021
|
+
displayName: 'Greenville County Schools',
|
|
1022
|
+
});
|
|
1023
|
+
expect(result.changes).toContainEqual(
|
|
1024
|
+
expect.objectContaining({
|
|
1025
|
+
resource: 'managedProfile',
|
|
1026
|
+
action: 'updated',
|
|
1027
|
+
detail: 'displayName',
|
|
1028
|
+
})
|
|
1029
|
+
);
|
|
1030
|
+
expect(manager.invoke.createManagedProfile).not.toHaveBeenCalled();
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
describe('service-account lookup', () => {
|
|
1036
|
+
const page = (index: number) =>
|
|
1037
|
+
Array.from({ length: 100 }, (_, i) => ({
|
|
1038
|
+
id: `other-${index}-${i}`,
|
|
1039
|
+
name: `other-${index}-${i}`,
|
|
1040
|
+
status: 'active',
|
|
1041
|
+
scope: 'inbox:read',
|
|
1042
|
+
createdAt: new Date(Date.UTC(2026, 0, 1, 0, index, i)).toISOString(),
|
|
1043
|
+
}));
|
|
1044
|
+
|
|
1045
|
+
it('pages through grants before deciding an account is absent', async () => {
|
|
1046
|
+
await withTmpProject(async project => {
|
|
1047
|
+
const card = makeExistingCard();
|
|
1048
|
+
const first = page(1);
|
|
1049
|
+
card.invoke.getAuthGrants
|
|
1050
|
+
.mockResolvedValueOnce(first)
|
|
1051
|
+
.mockResolvedValueOnce([
|
|
1052
|
+
{ ...matchingGrant, createdAt: '2025-12-31T00:00:00.000Z' },
|
|
1053
|
+
]);
|
|
1054
|
+
const result = await applyOrg(accountSpec, card, project);
|
|
1055
|
+
expect(card.invoke.getAuthGrants).toHaveBeenCalledTimes(2);
|
|
1056
|
+
expect(card.invoke.getAuthGrants).toHaveBeenNthCalledWith(1, {
|
|
1057
|
+
limit: 100,
|
|
1058
|
+
cursor: undefined,
|
|
1059
|
+
query: { name: 'ea-clr-issuer', status: 'active' },
|
|
1060
|
+
});
|
|
1061
|
+
expect(card.invoke.getAuthGrants).toHaveBeenNthCalledWith(2, {
|
|
1062
|
+
limit: 100,
|
|
1063
|
+
cursor: first.at(-1)!.createdAt,
|
|
1064
|
+
query: { name: 'ea-clr-issuer', status: 'active' },
|
|
1065
|
+
});
|
|
1066
|
+
expect(card.invoke.addAuthGrant).not.toHaveBeenCalled();
|
|
1067
|
+
expect(result.outputs.serviceAccounts).toEqual([
|
|
1068
|
+
{ name: 'ea-clr-issuer', grantId: 'grant-1', created: false },
|
|
1069
|
+
]);
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it('stops paging on a short page and creates the grant', async () => {
|
|
1074
|
+
await withTmpProject(async project => {
|
|
1075
|
+
const card = makeExistingCard();
|
|
1076
|
+
card.invoke.getAuthGrants.mockResolvedValueOnce(page(1)).mockResolvedValueOnce([]);
|
|
1077
|
+
const secretsOut = path.join(path.dirname(project.envPath), 'secrets.env');
|
|
1078
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1079
|
+
await applyOrg(accountSpec, card, project, { secretsOut });
|
|
1080
|
+
expect(card.invoke.getAuthGrants).toHaveBeenCalledTimes(2);
|
|
1081
|
+
expect(card.invoke.addAuthGrant).toHaveBeenCalledOnce();
|
|
1082
|
+
log.mockRestore();
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it('refuses to reconcile while another apply holds the secrets lock, and releases its own', async () => {
|
|
1087
|
+
await withTmpProject(async project => {
|
|
1088
|
+
const card = makeExistingCard();
|
|
1089
|
+
card.invoke.getAuthGrants.mockResolvedValue([]);
|
|
1090
|
+
const dir = path.dirname(project.envPath);
|
|
1091
|
+
const secretsOut = path.join(dir, 'secrets.env');
|
|
1092
|
+
const lockPath = path.join(dir, '.secrets.env.lock');
|
|
1093
|
+
await fs.writeFile(lockPath, '');
|
|
1094
|
+
await expect(applyOrg(accountSpec, card, project, { secretsOut })).rejects.toThrow(
|
|
1095
|
+
'Another org apply is reconciling service accounts'
|
|
1096
|
+
);
|
|
1097
|
+
expect(card.invoke.getAuthGrants).not.toHaveBeenCalled();
|
|
1098
|
+
expect(card.invoke.addAuthGrant).not.toHaveBeenCalled();
|
|
1099
|
+
await fs.rm(lockPath);
|
|
1100
|
+
|
|
1101
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1102
|
+
await applyOrg(accountSpec, card, project, { secretsOut });
|
|
1103
|
+
expect(card.invoke.addAuthGrant).toHaveBeenCalledOnce();
|
|
1104
|
+
expect(await fs.readdir(dir)).not.toContain('.secrets.env.lock');
|
|
1105
|
+
log.mockRestore();
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
});
|