| … | … | @@ -209,21 +209,6 @@ test('move resource between orgs', async (t) => { |
| 209 | 209 | t.is(b.resources[0], 'project') |
| 210 | 210 | }) |
| 211 | 211 | |
| 212 | | test('adding a resource moves it from any previous owner', async (t) => { |
| 213 | | const orgs = await makeOrgs(t, 'test-14') |
| 214 | | |
| 215 | | await orgs.create('team-a', 'group') |
| 216 | | await orgs.create('team-b', 'group') |
| 217 | | await orgs.addResource('team-a', 'shared-repo') |
| 218 | | |
| 219 | | // Add to team-b directly — should remove from team-a (apply-side logic) |
| 220 | | await orgs.addResource('team-b', 'shared-repo') |
| 221 | | |
| 222 | | const a = await orgs.get('team-a') |
| 223 | | const b = await orgs.get('team-b') |
| 224 | | t.is(a.resources.length, 0) |
| 225 | | t.is(b.resources.length, 1) |
| 226 | | }) |
| 227 | 212 | |
| 228 | 213 | // ── Grants ──────────────────────────────────────────────────── |
| 229 | 214 | |
| … | … | @@ -276,122 +261,6 @@ test('listGrants filters revoked by default', async (t) => { |
| 276 | 261 | t.is(all.length, 2) |
| 277 | 262 | }) |
| 278 | 263 | |
| 279 | | // ── Invites ─────────────────────────────────────────────────── |
| 280 | | |
| 281 | | test('invites', async (t) => { |
| 282 | | const orgs = await makeOrgs(t, 'test-17') |
| 283 | | |
| 284 | | await orgs.create('team', 'group') |
| 285 | | |
| 286 | | const invite = await orgs.createInvite({ |
| 287 | | inviteType: 'group', |
| 288 | | targetGroup: 'team', |
| 289 | | permission: 'write', |
| 290 | | maxUses: 2 |
| 291 | | }) |
| 292 | | |
| 293 | | t.ok(invite.inviteId) |
| 294 | | t.is(invite.usedCount, 0) |
| 295 | | t.is(invite.maxUses, 2) |
| 296 | | |
| 297 | | const grant = await orgs.consumeInvite(invite.inviteId, { |
| 298 | | subjectType: 'user', |
| 299 | | subjectId: 'bob' |
| 300 | | }) |
| 301 | | |
| 302 | | t.ok(grant) |
| 303 | | t.is(grant.permission, 'write') |
| 304 | | t.is(grant.groupName, 'team') |
| 305 | | |
| 306 | | const updatedInvite = await orgs.getInvite(invite.inviteId) |
| 307 | | t.is(updatedInvite.usedCount, 1) |
| 308 | | }) |
| 309 | | |
| 310 | | test('revoked invite cannot be consumed', async (t) => { |
| 311 | | const orgs = await makeOrgs(t, 'test-18') |
| 312 | | |
| 313 | | await orgs.create('team', 'group') |
| 314 | | const invite = await orgs.createInvite({ |
| 315 | | inviteType: 'group', targetGroup: 'team', permission: 'write' |
| 316 | | }) |
| 317 | | await orgs.revokeInvite(invite.inviteId) |
| 318 | | |
| 319 | | try { |
| 320 | | await orgs.consumeInvite(invite.inviteId, { |
| 321 | | subjectType: 'user', subjectId: 'bob' |
| 322 | | }) |
| 323 | | t.fail('should have thrown') |
| 324 | | } catch (err) { |
| 325 | | t.ok(err.message.toLowerCase().includes('revoked')) |
| 326 | | } |
| 327 | | }) |
| 328 | | |
| 329 | | // ── Access Requests ─────────────────────────────────────────── |
| 330 | | |
| 331 | | test('access request approve flow', async (t) => { |
| 332 | | const orgs = await makeOrgs(t, 'test-19') |
| 333 | | |
| 334 | | await orgs.create('team', 'group') |
| 335 | | |
| 336 | | const request = await orgs.createAccessRequest({ |
| 337 | | subjectType: 'user', |
| 338 | | subjectId: 'charlie', |
| 339 | | requestType: 'group', |
| 340 | | groupName: 'team', |
| 341 | | permission: 'write', |
| 342 | | reason: 'Want to contribute' |
| 343 | | }) |
| 344 | | |
| 345 | | t.is(request.status, 'pending') |
| 346 | | |
| 347 | | const grant = await orgs.approveAccessRequest(request.requestId, 'admin') |
| 348 | | t.ok(grant) |
| 349 | | t.is(grant.permission, 'write') |
| 350 | | t.is(grant.groupName, 'team') |
| 351 | | |
| 352 | | const updatedReq = await orgs.getAccessRequest(request.requestId) |
| 353 | | t.is(updatedReq.status, 'approved') |
| 354 | | }) |
| 355 | | |
| 356 | | test('access request deny flow', async (t) => { |
| 357 | | const orgs = await makeOrgs(t, 'test-20') |
| 358 | | |
| 359 | | await orgs.create('team', 'group') |
| 360 | | |
| 361 | | const request = await orgs.createAccessRequest({ |
| 362 | | subjectType: 'user', |
| 363 | | subjectId: 'charlie', |
| 364 | | requestType: 'group', |
| 365 | | groupName: 'team', |
| 366 | | permission: 'write' |
| 367 | | }) |
| 368 | | |
| 369 | | await orgs.denyAccessRequest(request.requestId, 'admin', 'Not yet') |
| 370 | | const updated = await orgs.getAccessRequest(request.requestId) |
| 371 | | t.is(updated.status, 'denied') |
| 372 | | t.is(updated.denyReason, 'Not yet') |
| 373 | | }) |
| 374 | | |
| 375 | | test('duplicate pending access request rejected', async (t) => { |
| 376 | | const orgs = await makeOrgs(t, 'test-21') |
| 377 | | |
| 378 | | await orgs.create('team', 'group') |
| 379 | | await orgs.createAccessRequest({ |
| 380 | | subjectType: 'user', subjectId: 'charlie', |
| 381 | | requestType: 'group', groupName: 'team', permission: 'write' |
| 382 | | }) |
| 383 | | |
| 384 | | try { |
| 385 | | await orgs.createAccessRequest({ |
| 386 | | subjectType: 'user', subjectId: 'charlie', |
| 387 | | requestType: 'group', groupName: 'team', permission: 'write' |
| 388 | | }) |
| 389 | | t.fail('should have thrown') |
| 390 | | } catch (err) { |
| 391 | | t.ok(err.message.toLowerCase().includes('pending')) |
| 392 | | } |
| 393 | | }) |
| 394 | | |
| 395 | 264 | // ── Access Checking ─────────────────────────────────────────── |
| 396 | 265 | |
| 397 | 266 | test('checkAccess — denied when no grants', async (t) => { |
| … | … | @@ -473,110 +342,3 @@ test('checkAccess — server grants', async (t) => { |
| 473 | 342 | }) |
| 474 | 343 | t.is(allowed, true) |
| 475 | 344 | }) |
| 476 | | |
| 477 | | // ── Meta ────────────────────────────────────────────────────── |
| 478 | | |
| 479 | | test('meta key-value storage', async (t) => { |
| 480 | | const orgs = await makeOrgs(t, 'test-26') |
| 481 | | |
| 482 | | await orgs.setMeta('homepage', 'https://example.com') |
| 483 | | await orgs.setMeta('tagline', 'p2p forge') |
| 484 | | |
| 485 | | t.is(await orgs.getMeta('homepage'), 'https://example.com') |
| 486 | | t.is(await orgs.getMeta('tagline'), 'p2p forge') |
| 487 | | t.is(await orgs.getMeta('missing'), null) |
| 488 | | }) |
| 489 | | |
| 490 | | // ── Keys ────────────────────────────────────────────────────── |
| 491 | | |
| 492 | | test('key properties', async (t) => { |
| 493 | | const orgs = await makeOrgs(t, 'test-27') |
| 494 | | |
| 495 | | t.ok(orgs.key, 'has key') |
| 496 | | t.is(typeof orgs.keyHex, 'string') |
| 497 | | t.is(orgs.keyHex.length, 64, 'keyHex is 32 bytes hex') |
| 498 | | t.ok(orgs.localKey, 'has localKey') |
| 499 | | t.is(orgs.writable, true, 'first writer is writable') |
| 500 | | }) |
| 501 | | |
| 502 | | // ── Multi-writer replication ────────────────────────────────── |
| 503 | | |
| 504 | | test('two orgs instances replicate via autobase', async (t) => { |
| 505 | | const storeA = tmpStore() |
| 506 | | const storeB = tmpStore() |
| 507 | | |
| 508 | | const orgsA = new HyperOrgs(storeA, { namespace: 'shared' }) |
| 509 | | await orgsA.ready() |
| 510 | | |
| 511 | | // A creates an org and a grant |
| 512 | | await orgsA.create('team', 'group', { displayName: 'Team A' }) |
| 513 | | await orgsA.addMember('team', 'alice', 'admin') |
| 514 | | await orgsA.addResource('team', 'repo-1') |
| 515 | | await orgsA.base.update() |
| 516 | | |
| 517 | | const orgsB = new HyperOrgs(storeB, { |
| 518 | | namespace: 'shared', |
| 519 | | bootstrapKey: orgsA.keyHex |
| 520 | | }) |
| 521 | | await orgsB.ready() |
| 522 | | |
| 523 | | await replicateAndSync([orgsA.base, orgsB.base]) |
| 524 | | |
| 525 | | // B should see A's data |
| 526 | | const org = await orgsB.get('team') |
| 527 | | t.ok(org, 'B received the org') |
| 528 | | t.is(org.displayName, 'Team A') |
| 529 | | t.is(org.members.length, 1) |
| 530 | | t.is(org.members[0].userId, 'alice') |
| 531 | | t.alike(org.resources, ['repo-1']) |
| 532 | | |
| 533 | | t.teardown(async () => { |
| 534 | | try { await orgsA.close() } catch {} |
| 535 | | try { await orgsB.close() } catch {} |
| 536 | | try { await storeA.close() } catch {} |
| 537 | | try { await storeB.close() } catch {} |
| 538 | | }) |
| 539 | | }) |
| 540 | | |
| 541 | | test('two writers can both append after addWriter', async (t) => { |
| 542 | | const storeA = tmpStore() |
| 543 | | const storeB = tmpStore() |
| 544 | | |
| 545 | | const orgsA = new HyperOrgs(storeA, { namespace: 'shared2' }) |
| 546 | | await orgsA.ready() |
| 547 | | |
| 548 | | const orgsB = new HyperOrgs(storeB, { |
| 549 | | namespace: 'shared2', |
| 550 | | bootstrapKey: orgsA.keyHex |
| 551 | | }) |
| 552 | | await orgsB.ready() |
| 553 | | |
| 554 | | // Initial sync so B knows the room structure |
| 555 | | await replicateAndSync([orgsA.base, orgsB.base]) |
| 556 | | |
| 557 | | // A authorizes B as a writer |
| 558 | | await orgsA.addWriter('bob', { writerKey: orgsB.localKeyHex, role: 'writer' }) |
| 559 | | await replicateAndSync([orgsA.base, orgsB.base]) |
| 560 | | |
| 561 | | t.ok(orgsB.writable, 'B is now writable') |
| 562 | | |
| 563 | | // Each creates a distinct org (no ID collision — orgs are keyed by name) |
| 564 | | await orgsA.create('team-alice', 'group') |
| 565 | | await orgsB.create('team-bob', 'group') |
| 566 | | |
| 567 | | await replicateAndSync([orgsA.base, orgsB.base]) |
| 568 | | |
| 569 | | const allA = await orgsA.list() |
| 570 | | const allB = await orgsB.list() |
| 571 | | |
| 572 | | t.is(allA.length, 2, 'A sees both orgs') |
| 573 | | t.is(allB.length, 2, 'B sees both orgs') |
| 574 | | t.alike(allA.map(o => o.name).sort(), ['team-alice', 'team-bob']) |
| 575 | | |
| 576 | | t.teardown(async () => { |
| 577 | | try { await orgsA.close() } catch {} |
| 578 | | try { await orgsB.close() } catch {} |
| 579 | | try { await storeA.close() } catch {} |
| 580 | | try { await storeB.close() } catch {} |
| 581 | | }) |
| 582 | | }) |