@sleep2agi/commhub-server 0.9.0-preview.111 → 0.9.0-preview.112
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/package.json +1 -1
- package/src/binding-generation-http.test.ts +121 -0
- package/src/create-node.ts +70 -26
- package/src/daemon-token-owner-http.test.ts +10 -5
- package/src/node-caller-identity-http.test.ts +714 -0
- package/src/node-daemon-bindings.test.ts +1 -1
- package/src/node-permissions.ts +45 -12
- package/src/requirements.ts +3 -7
- package/src/rest-identity.ts +29 -4
- package/src/server.ts +58 -18
- package/src/tools.ts +47 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.112",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { beforeAll, afterAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { preflightCodexStart } from "../../agent-node/src/runtime/adopt-codex-start-preflight.js";
|
|
4
|
+
|
|
5
|
+
process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/binding-http-")}/hub.db`;
|
|
6
|
+
process.env.COMMHUB_AUTH_TOKEN ||= "binding-master-fixture";
|
|
7
|
+
let db:any, hub:ReturnType<typeof Bun.serve>, base:string, owner:any, other:any;
|
|
8
|
+
const daemon="n_binding_daemon", child="n_binding_child";
|
|
9
|
+
let token:string, wrong:string, cross:string, ordinary:string, revoked:string;
|
|
10
|
+
async function mcp(credential:string,name:string,args:Record<string,unknown>={}) {
|
|
11
|
+
const res=await fetch(`${base}/mcp`, {method:"POST",headers:{Authorization:`Bearer ${credential}`,
|
|
12
|
+
"Content-Type":"application/json",Accept:"application/json, text/event-stream","MCP-Protocol-Version":"2025-03-26"},
|
|
13
|
+
body:JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/call",params:{name,arguments:args}})});
|
|
14
|
+
const raw=await res.text();
|
|
15
|
+
if(res.status!==200)return {ok:false,http_status:res.status};
|
|
16
|
+
const lines=raw.split("\n").filter(s=>s.startsWith("data:"));
|
|
17
|
+
const payload=JSON.parse(lines.length?lines.at(-1)!.slice(5):raw);
|
|
18
|
+
return payload.result?.content?.[0]?.text ? JSON.parse(payload.result.content[0].text) : {ok:false,error:payload.error};
|
|
19
|
+
}
|
|
20
|
+
const list=(t=token)=>mcp(t,"list_my_children");
|
|
21
|
+
const request=()=>mcp(owner.token,"request_adopt_node",{node_id:child,daemon_node_id:daemon,workdir:"/fixture/project"});
|
|
22
|
+
const ack=(id:string,status="adopted")=>mcp(token,"ack_adopt_request",{request_id:id,status});
|
|
23
|
+
const unadopt=()=>mcp(owner.token,"unadopt_node",{node_id:child});
|
|
24
|
+
async function activate(){const r=await request();expect(r.ok).toBe(true);expect((await ack(r.request_id)).status).toBe("active");return r.request_id;}
|
|
25
|
+
beforeAll(async()=>{
|
|
26
|
+
({db}=await import("./db.js"));
|
|
27
|
+
const {register}=await import("./auth.js");
|
|
28
|
+
owner=register("binding_owner","Fixture-Strong1!");other=register("binding_other","Fixture-Strong1!");
|
|
29
|
+
expect(owner.ok&&other.ok).toBe(true);
|
|
30
|
+
db.run("UPDATE users SET role='user' WHERE user_id=?1",[other.user.user_id]);
|
|
31
|
+
db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')",[owner.network_id,other.user.user_id]);
|
|
32
|
+
const {generateNetworkToken,hashToken}=await import("./db.js");
|
|
33
|
+
function node(id:string,network:string,user:string,supervisor=true){
|
|
34
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state) VALUES(?1,?1,?1,?2,?3,'fixture-host',?4,'active')",
|
|
35
|
+
[id,network,user,JSON.stringify(supervisor?{role:"host_supervisor",daemon_capabilities:{adopt_capable:true}}:{})]);
|
|
36
|
+
db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES(?1,?1,?1,?2,'idle',datetime('now'))",[id,network]);
|
|
37
|
+
const raw=generateNetworkToken();
|
|
38
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES(?1,?2,?3,'network',?4,?5)",[id,user,network,`node:${id}`,hashToken(raw)]);
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
token=node(daemon,owner.network_id,owner.user.user_id);
|
|
42
|
+
wrong=node("n_binding_other",owner.network_id,other.user.user_id);
|
|
43
|
+
cross=node("n_binding_cross",other.network_id,other.user.user_id);
|
|
44
|
+
node(child,owner.network_id,owner.user.user_id,false);
|
|
45
|
+
ordinary=generateNetworkToken();revoked=generateNetworkToken();
|
|
46
|
+
for(const [id,raw,name,dead] of [["ordinary",ordinary,"general",null],["revoked",revoked,`node:${daemon}`,"2020-01-01"]])
|
|
47
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash,revoked_at) VALUES(?1,?2,?3,'network',?4,?5,?6)",[id,owner.user.user_id,owner.network_id,name,hashToken(raw as string),dead]);
|
|
48
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,lifecycle_state) VALUES('node_binding_created','created','created',?1,'active')",[owner.network_id]);
|
|
49
|
+
db.run("INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,flags_json,env_keys,status,created_at,created_by_token) VALUES('cr_binding_created',?1,'created',?2,'codex-sdk','{}','[]','succeeded',1,'fixture')",[daemon,owner.network_id]);
|
|
50
|
+
const {bootServer}=await import("./server.js");hub=bootServer({port:0,hostname:"127.0.0.1"});base=`http://127.0.0.1:${hub.port}`;
|
|
51
|
+
},60000);
|
|
52
|
+
afterAll(()=>hub?.stop(true));
|
|
53
|
+
|
|
54
|
+
test("generation projection and unchanged created row",async()=>{
|
|
55
|
+
const id=await activate();
|
|
56
|
+
const r=await list();expect(r.ok).toBe(true);
|
|
57
|
+
expect((await mcp(token,"get_adopt_request",{request_id:id})).error).toBe("request_not_pending");
|
|
58
|
+
expect(r.children.find((n:any)=>n.child_node_id===child)).toEqual({child_node_id:child,alias:child,lifecycle_state:"active",managed:"adopted",binding_request_id:id});
|
|
59
|
+
expect(r.children.find((n:any)=>n.child_node_id==="node_binding_created")).toEqual({child_node_id:"node_binding_created",alias:"created",lifecycle_state:"active"});
|
|
60
|
+
// Old consumers continue reading exactly the same fields; additive only.
|
|
61
|
+
expect(r.children.map(({child_node_id,alias,lifecycle_state,managed}:any)=>({child_node_id,alias,lifecycle_state,managed}))).toContainEqual({child_node_id:child,alias:child,lifecycle_state:"active",managed:"adopted"});
|
|
62
|
+
expect((await unadopt()).ok).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
test("token boundary denies humans general revoked and other owners",async()=>{
|
|
65
|
+
await activate();
|
|
66
|
+
for(const t of [owner.token,other.token,ordinary,revoked])expect((await list(t)).ok).toBe(false);
|
|
67
|
+
for(const t of [wrong,cross]){const r=await list(t);expect(r.ok).toBe(true);expect(r.children).toEqual([]);}
|
|
68
|
+
expect((await unadopt()).ok).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
test("child node token sees an empty list, not its parent binding",async()=>{
|
|
71
|
+
const id=await activate();
|
|
72
|
+
try {
|
|
73
|
+
const issued=await fetch(`${base}/api/auth/node-token`,{method:"POST",
|
|
74
|
+
headers:{Authorization:`Bearer ${owner.token}`,"Content-Type":"application/json"},
|
|
75
|
+
body:JSON.stringify({network_id:owner.network_id,node_name:child,node_id:child})});
|
|
76
|
+
expect(issued.status).toBe(200);
|
|
77
|
+
const credential=await issued.json() as any;
|
|
78
|
+
expect(credential.ok).toBe(true);
|
|
79
|
+
expect(typeof credential.token).toBe("string");
|
|
80
|
+
// Same owner and network do not confer the parent's daemon identity.
|
|
81
|
+
expect((await list()).children.find((n:any)=>n.child_node_id===child).binding_request_id).toBe(id);
|
|
82
|
+
expect(await list(credential.token)).toEqual({ok:true,count:0,children:[]});
|
|
83
|
+
} finally {expect((await unadopt()).ok).toBe(true);}
|
|
84
|
+
});
|
|
85
|
+
test("network predicate rejects corrupted cross-network binding sentinel",async()=>{
|
|
86
|
+
const id=await activate();
|
|
87
|
+
// Deliberately inconsistent test DB: daemon stays in A, binding and child in B.
|
|
88
|
+
// This makes the network predicate independently load-bearing, HTTP still real.
|
|
89
|
+
try {
|
|
90
|
+
db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2",[other.network_id,id]);
|
|
91
|
+
db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[other.network_id,child]);
|
|
92
|
+
expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
93
|
+
} finally {
|
|
94
|
+
db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2",[owner.network_id,id]);
|
|
95
|
+
db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[owner.network_id,child]);await unadopt();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
test("node join rejects mismatched node network sentinel",async()=>{
|
|
99
|
+
await activate();
|
|
100
|
+
try {db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[other.network_id,child]);expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);}
|
|
101
|
+
finally {db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[owner.network_id,child]);await unadopt();}
|
|
102
|
+
});
|
|
103
|
+
test("non-active bindings never project generation",async()=>{
|
|
104
|
+
const r=await request();expect(r.ok).toBe(true);
|
|
105
|
+
expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
106
|
+
expect((await ack(r.request_id,"refused")).status).toBe("refused");
|
|
107
|
+
expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
108
|
+
await activate();await unadopt();expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
test("revocation rotates generation and old acknowledgements cannot revive",async()=>{
|
|
111
|
+
const r1=await activate();await unadopt();
|
|
112
|
+
expect((await ack(r1)).error).toBe("request_not_pending");
|
|
113
|
+
const r2=await request();expect(r2.ok).toBe(true);expect(r2.request_id).not.toBe(r1);
|
|
114
|
+
expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
|
|
115
|
+
expect((await ack(r2.request_id)).status).toBe("active");
|
|
116
|
+
const current=(await list()).children.find((n:any)=>n.child_node_id===child).binding_request_id;
|
|
117
|
+
expect(current).toBe(r2.request_id);expect(current).not.toBe(r1);
|
|
118
|
+
// Real HTTP authority feeds the existing daemon gate; later probes must not run.
|
|
119
|
+
for(const authority of [current,undefined])await expect(preflightCodexStart({request_id:r1} as any,{} as any,{} as any,authority,()=>true)).rejects.toThrow("adopt_codex_binding_generation_unproven");
|
|
120
|
+
await unadopt();
|
|
121
|
+
});
|
package/src/create-node.ts
CHANGED
|
@@ -232,47 +232,91 @@ export type DaemonResolveResult =
|
|
|
232
232
|
| { ok: true; daemonNodeId: string; daemonAlias: string; networkId: string }
|
|
233
233
|
| { ok: false; error: string };
|
|
234
234
|
|
|
235
|
-
export
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
235
|
+
export type NodeCallerResolution =
|
|
236
|
+
| { ok: true; kind: "node"; nodeId: string; alias: string; networkId: string }
|
|
237
|
+
| { ok: true; kind: "unregistered"; alias: string; networkId: string }
|
|
238
|
+
| { ok: false; reason: "not_a_node_token" | "bound_node_missing" | "ambiguous" | "not_owner" };
|
|
239
|
+
|
|
240
|
+
type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
241
|
+
|
|
242
|
+
// nodes has no unique (network, alias). An unbound token that hits several
|
|
243
|
+
// rows is not rejected outright: the row owned by the token's issuer wins;
|
|
244
|
+
// if that is still not unique, the node_id on the latest session for the
|
|
245
|
+
// alias wins. Still not unique, then refuse.
|
|
246
|
+
function disambiguateUnboundAlias(
|
|
247
|
+
rows: NodeCallerRow[],
|
|
248
|
+
userId: string | null,
|
|
249
|
+
alias: string,
|
|
250
|
+
networkId: string,
|
|
251
|
+
): NodeCallerRow | undefined {
|
|
252
|
+
const owned = userId ? rows.filter((row) => row.owner_user_id === userId) : [];
|
|
253
|
+
if (owned.length === 1) return owned[0]; // board679-unbound-dup
|
|
254
|
+
const pool = owned.length > 1 ? owned : rows;
|
|
255
|
+
const recent = db.get<{ node_id: string | null }>(
|
|
256
|
+
`SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1`,
|
|
257
|
+
alias,
|
|
258
|
+
networkId,
|
|
259
|
+
);
|
|
260
|
+
const hit = recent?.node_id ? pool.filter((row) => row.node_id === recent.node_id) : [];
|
|
261
|
+
return hit.length === 1 ? hit[0] : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// One lookup for every caller-identity entry. The token name is a hint.
|
|
265
|
+
// bound_node_id is the row: if that row is gone, refuse — do not look up by name.
|
|
266
|
+
// An unbound token with no row in its network stays the token name. First
|
|
267
|
+
// registration (report_status) still depends on that; sending and subscribing
|
|
268
|
+
// do not get a second exception.
|
|
269
|
+
export function resolveNodeCaller(tokenId: string | null | undefined): NodeCallerResolution {
|
|
270
|
+
if (!tokenId) return { ok: false, reason: "not_a_node_token" };
|
|
246
271
|
const tokRow = db.get<{ name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number }>(
|
|
247
272
|
`SELECT name, network_id, user_id, bound_node_id, node_identity_epoch FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
|
|
248
|
-
|
|
273
|
+
tokenId,
|
|
249
274
|
);
|
|
250
|
-
if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
|
|
251
|
-
return { ok: false,
|
|
252
|
-
}
|
|
253
|
-
if (tokRow.network_id !== opts.enforceNetworkId) {
|
|
254
|
-
return { ok: false, error: "caller_not_a_daemon" };
|
|
275
|
+
if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:") || !tokRow.network_id) {
|
|
276
|
+
return { ok: false, reason: "not_a_node_token" };
|
|
255
277
|
}
|
|
256
278
|
const tokenAlias = tokRow.name.slice(5);
|
|
257
279
|
type DaemonRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
258
280
|
const rows = tokRow.bound_node_id
|
|
259
281
|
? db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`, tokRow.bound_node_id, tokRow.network_id)
|
|
260
282
|
: db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE alias = ?1 AND network_id = ?2`, tokenAlias, tokRow.network_id);
|
|
261
|
-
const nodeRow = rows.length === 1
|
|
262
|
-
|
|
263
|
-
|
|
283
|
+
const nodeRow = rows.length === 1
|
|
284
|
+
? rows[0]
|
|
285
|
+
: (!tokRow.bound_node_id && rows.length > 1
|
|
286
|
+
? disambiguateUnboundAlias(rows, tokRow.user_id, tokenAlias, tokRow.network_id)
|
|
287
|
+
: undefined);
|
|
288
|
+
// board679-bound-row-missing: a bound token with no row is refused. Do not look up by name.
|
|
289
|
+
if (tokRow.bound_node_id && !nodeRow) return { ok: false, reason: "bound_node_missing" };
|
|
290
|
+
if (!nodeRow && rows.length === 0) {
|
|
291
|
+
return { ok: true, kind: "unregistered", alias: tokenAlias, networkId: tokRow.network_id };
|
|
264
292
|
}
|
|
265
|
-
|
|
266
|
-
//
|
|
293
|
+
if (!nodeRow) return { ok: false, reason: "ambiguous" };
|
|
294
|
+
// Legacy unbound tokens keep their owner's authority. A token name is only a hint.
|
|
267
295
|
const ownsNode = !!tokRow.user_id && tokRow.user_id === nodeRow.owner_user_id;
|
|
268
296
|
const boundToNode = tokRow.bound_node_id === nodeRow.node_id;
|
|
269
297
|
const legacyOwnerless = nodeRow.owner_user_id === null && tokRow.node_identity_epoch === 0;
|
|
270
|
-
if (!ownsNode && !boundToNode && !legacyOwnerless) return { ok: false,
|
|
298
|
+
if (!ownsNode && !boundToNode && !legacyOwnerless) return { ok: false, reason: "not_owner" };
|
|
299
|
+
return { ok: true, kind: "node", nodeId: nodeRow.node_id, alias: nodeRow.alias, networkId: nodeRow.network_id };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function resolveCallerDaemonTokenBound(opts: {
|
|
303
|
+
callerTokenIsNetwork: boolean;
|
|
304
|
+
callerTokenId: string | null | undefined;
|
|
305
|
+
enforceNetworkId: string | null | undefined;
|
|
306
|
+
}): DaemonResolveResult {
|
|
307
|
+
if (!opts.callerTokenIsNetwork || !opts.callerTokenId || !opts.enforceNetworkId) {
|
|
308
|
+
return { ok: false, error: "caller_not_a_daemon" };
|
|
309
|
+
}
|
|
310
|
+
const resolved = resolveNodeCaller(opts.callerTokenId);
|
|
311
|
+
// No row yet is not a daemon. First registration stays on report_status.
|
|
312
|
+
if (!resolved.ok || resolved.kind !== "node" || resolved.networkId !== opts.enforceNetworkId) {
|
|
313
|
+
return { ok: false, error: "caller_not_a_daemon" };
|
|
314
|
+
}
|
|
271
315
|
return {
|
|
272
316
|
ok: true,
|
|
273
|
-
daemonNodeId:
|
|
274
|
-
daemonAlias:
|
|
275
|
-
networkId:
|
|
317
|
+
daemonNodeId: resolved.nodeId,
|
|
318
|
+
daemonAlias: resolved.alias,
|
|
319
|
+
networkId: resolved.networkId,
|
|
276
320
|
};
|
|
277
321
|
}
|
|
278
322
|
|
|
@@ -116,7 +116,7 @@ test("legacy owner and exact bound identity remain supported", async () => {
|
|
|
116
116
|
const denied = await fetch(`${base}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${revoked}` } });
|
|
117
117
|
expect(denied.status).toBe(401);
|
|
118
118
|
expect(await call(legacy, "ack_adopt_request", { request_id: requestId, status: "adopted" })).toMatchObject({ ok: true, status: "active" });
|
|
119
|
-
expect((await call(legacy, "list_my_children")).children).toContainEqual({ child_node_id: child, alias: child, lifecycle_state: "active", managed: "adopted" });
|
|
119
|
+
expect((await call(legacy, "list_my_children")).children).toContainEqual({ child_node_id: child, alias: child, lifecycle_state: "active", managed: "adopted", binding_request_id: requestId });
|
|
120
120
|
const stop = await call(owner.token, "stop_node", { node_id: child, network_id: owner.network_id });
|
|
121
121
|
expect(stop.ok).toBe(true);
|
|
122
122
|
expect((await call(legacy, "get_stop_request", { request_id: stop.request_id })).ok).toBe(true);
|
|
@@ -169,9 +169,10 @@ test("fresh ID cannot mint another node alias", async () => {
|
|
|
169
169
|
expect((await call(legacy, "list_my_children")).ok).toBe(true);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
test("duplicate legacy aliases use bound IDs and unbound
|
|
172
|
+
test("duplicate legacy aliases use bound IDs and an unbound token follows its owner", async () => {
|
|
173
173
|
// Historical duplicate rows: the member's row was inserted first. Neither
|
|
174
|
-
// lookup order nor alias can redirect
|
|
174
|
+
// lookup order nor alias can redirect an exact-bound token. An unbound
|
|
175
|
+
// token resolves to the row its issuer owns.
|
|
175
176
|
const { hashToken, generateNetworkToken } = await import("./db.js");
|
|
176
177
|
const tokens: string[] = [];
|
|
177
178
|
for (const [id, user] of [["n_duplicate_first", member.user.user_id], ["n_duplicate_owner", owner.user.user_id]]) {
|
|
@@ -188,9 +189,13 @@ test("duplicate legacy aliases use bound IDs and unbound ambiguity fails closed"
|
|
|
188
189
|
expect((await call(tokens[i], "get_adopt_request", { request_id: i === 0 ? "n_duplicate_owner" : "n_duplicate_first" })).ok).toBe(false);
|
|
189
190
|
}
|
|
190
191
|
db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_owner'");
|
|
191
|
-
expect(await call(tokens[1], "list_my_children")).
|
|
192
|
+
expect((await call(tokens[1], "list_my_children")).ok).toBe(true);
|
|
193
|
+
expect((await call(tokens[1], "get_adopt_request", { request_id: "n_duplicate_owner" })).ok).toBe(true);
|
|
194
|
+
expect((await call(tokens[1], "get_adopt_request", { request_id: "n_duplicate_first" })).ok).toBe(false);
|
|
192
195
|
db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_first'");
|
|
193
|
-
expect(await call(tokens[0], "list_my_children")).
|
|
196
|
+
expect((await call(tokens[0], "list_my_children")).ok).toBe(true);
|
|
197
|
+
expect((await call(tokens[0], "get_adopt_request", { request_id: "n_duplicate_first" })).ok).toBe(true);
|
|
198
|
+
expect((await call(tokens[0], "get_adopt_request", { request_id: "n_duplicate_owner" })).ok).toBe(false);
|
|
194
199
|
expect((await mint(owner.token, owner.network_id, "duplicate_fixture", "n_third_duplicate")).status).toBe(400);
|
|
195
200
|
});
|
|
196
201
|
|