tinyrelayfile
tinyrelay/internal/gates/jobs_test.go/

internal/gates/jobs_test.go raw
1package gates
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "strings"
8 "testing"
9
10 "github.com/FelineStateMachine/tinyrelay/internal/community"
11 "github.com/FelineStateMachine/tinyrelay/internal/event"
12 "github.com/FelineStateMachine/tinyrelay/internal/policy"
13 "github.com/FelineStateMachine/tinyrelay/internal/relay"
14 "github.com/FelineStateMachine/tinyrelay/internal/storage"
15)
16
17const jobMemberSecret = "0000000000000000000000000000000000000000000000000000000000000005"
18
19// jobFixture adds a human member who requests jobs to the agent fixture.
20type jobFixture struct {
21 agentFixture
22 member string
23}
24
25func newJobFixture(t *testing.T) jobFixture {
26 t.Helper()
27 f := newAgentFixture(t)
28 member, _ := event.PublicKey(jobMemberSecret)
29 raw, _ := json.Marshal(member)
30 if _, err := f.community.Execute(context.Background(), f.owner, "setmember", []json.RawMessage{raw}); err != nil {
31 t.Fatal(err)
32 }
33 return jobFixture{agentFixture: f, member: member}
34}
35
36// request publishes a job request from the member and stores it, so
37// answers can name a request the relay holds.
38func (f jobFixture) request(t *testing.T, kind int, createdAt int64) event.Event {
39 t.Helper()
40 ctx := context.Background()
41 e := signed(t, jobMemberSecret, kind, createdAt, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")
42 if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {
43 t.Fatalf("member request rejected: %v", err)
44 }
45 if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {
46 _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: agentNow})
47 return err
48 }); err != nil {
49 t.Fatal(err)
50 }
51 return e
52}
53
54func expectError(t *testing.T, err error, prefix, fragment string) {
55 t.Helper()
56 if err == nil || !strings.HasPrefix(err.Error(), prefix) || !strings.Contains(err.Error(), fragment) {
57 t.Fatalf("want %s error containing %q, got %v", prefix, fragment, err)
58 }
59}
60
61func TestJobShapeRefusesMalformedEvents(t *testing.T) {
62 f := newJobFixture(t)
63 ctx := context.Background()
64 session := relay.Session{PubKeys: []string{f.member}}
65 request := f.request(t, 5001, agentNow)
66 cases := []struct {
67 name string
68 kind int
69 tags [][]string
70 fragment string
71 }{
72 {"input without a type", 5001, [][]string{{"i", "hello"}}, "url, event, job or text"},
73 {"input with an unknown type", 5001, [][]string{{"i", "hello", "voice"}}, "url, event, job or text"},
74 {"event input without an id", 5001, [][]string{{"i", "abc", "event"}}, "event id"},
75 {"encrypted without a provider", 5001, [][]string{{"encrypted"}}, "p tag"},
76 {"bid that is not an amount", 5001, [][]string{{"bid", "ten"}}, "millisats"},
77 {"result without a request", 6001, [][]string{{"p", f.member}}, "e tag"},
78 {"result without a requester", 6001, [][]string{{"e", request.ID}}, "p tag"},
79 {"result with a bad amount", 6001, [][]string{{"e", request.ID}, {"p", f.member}, {"amount", "-1"}}, "millisats"},
80 {"result of the wrong kind", 6002, [][]string{{"e", request.ID}, {"p", f.member}}, "does not answer"},
81 {"result naming the wrong requester", 6001, [][]string{{"e", request.ID}, {"p", f.owner}}, "name the requester"},
82 {"feedback without a status", 7000, [][]string{{"e", request.ID}, {"p", f.member}}, "payment-required, processing, error, success or partial"},
83 {"feedback with an unknown status", 7000, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "done"}}, "payment-required, processing, error, success or partial"},
84 }
85 for _, tc := range cases {
86 err := f.gate.Write(ctx, signed(t, jobMemberSecret, tc.kind, agentNow, tc.tags, ""), session, agentNow)
87 expectError(t, err, "invalid:", tc.fragment)
88 }
89 // Well-formed answers from a member pass, including answers to a request
90 // made on another relay.
91 for _, e := range []event.Event{
92 signed(t, jobMemberSecret, 6001, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"amount", "500", "lnbc1"}}, "output"),
93 signed(t, jobMemberSecret, 7000, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "processing"}}, ""),
94 signed(t, jobMemberSecret, 6005, agentNow, [][]string{{"e", strings.Repeat("a", 64)}, {"p", f.owner}}, "elsewhere"),
95 } {
96 if err := f.gate.Write(ctx, e, session, agentNow); err != nil {
97 t.Fatalf("well-formed kind %d rejected: %v", e.Kind, err)
98 }
99 }
100 // Import applies the same shape rules.
101 expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, 7000, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "done"}}, ""), agentNow), "invalid:", "payment-required")
102}
103
104func TestJobPolicyAndMembership(t *testing.T) {
105 f := newJobFixture(t)
106 ctx := context.Background()
107 other, _ := event.PublicKey(agentOtherSecret)
108 request := signed(t, agentOtherSecret, 5001, agentNow, [][]string{{"i", "hello", "text"}}, "")
109 // The relay writes openly, but job kinds are for members and agents.
110 expectError(t, f.gate.Write(ctx, request, relay.Session{PubKeys: []string{other}}, agentNow), "restricted:", "members and agents")
111 if err := f.gate.Write(ctx, signed(t, agentOtherSecret, 1, agentNow, nil, "note"), relay.Session{PubKeys: []string{other}}, agentNow); err != nil {
112 t.Fatalf("open relay refused a note: %v", err)
113 }
114 f.request(t, 5001, agentNow)
115 if err := f.gate.Write(ctx, signed(t, agentOwnerSecret, 5002, agentNow, nil, ""), relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {
116 t.Fatalf("owner request rejected: %v", err)
117 }
118 // Switching long tasks off refuses every job kind, for members and on
119 // import alike.
120 p := f.gate.cfg.Policy()
121 p.Features.Jobs = false
122 f.gate.cfg.Policy = func() policy.Policy { return p }
123 expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, 5001, agentNow+1, nil, ""), relay.Session{PubKeys: []string{f.member}}, agentNow), "restricted:", "switched off")
124 expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, 7000, agentNow+1, [][]string{{"e", strings.Repeat("b", 64)}, {"p", f.member}, {"status", "success"}}, ""), agentNow), "restricted:", "switched off")
125 if err := f.gate.Write(ctx, signed(t, jobMemberSecret, 1, agentNow+1, nil, "still fine"), relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {
126 t.Fatalf("switching jobs off broke notes: %v", err)
127 }
128}
129
130func TestJobGrantsRequestServeAndBoth(t *testing.T) {
131 f := newJobFixture(t)
132 ctx := context.Background()
133 session := relay.Session{PubKeys: []string{f.agent}}
134 request := f.request(t, 5001, agentNow)
135 answer := func(kind int, extra ...[]string) event.Event {
136 tags := append([][]string{{"e", request.ID}, {"p", f.member}}, extra...)
137 return signed(t, agentSecret, kind, agentNow, tags, "output")
138 }
139
140 // request: 5xxx only.
141 f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "request"}))
142 if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, [][]string{{"i", "hello", "text"}}, ""), session, agentNow); err != nil {
143 t.Fatalf("request grant refused a request: %v", err)
144 }
145 expectRestricted(t, f.gate.Write(ctx, answer(6001), session, agentNow), "kind 6001")
146 expectRestricted(t, f.gate.Write(ctx, answer(7000, []string{"status", "processing"}), session, agentNow), "kind 7000")
147
148 // serve: 6xxx and 7000 in reply to a held request; no requests.
149 f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"jobs", "serve"}))
150 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow), "kind 5001")
151 if err := f.gate.Write(ctx, answer(6001, []string{"amount", "500"}), session, agentNow); err != nil {
152 t.Fatalf("serve grant refused a result: %v", err)
153 }
154 if err := f.gate.Write(ctx, answer(7000, []string{"status", "payment-required"}, []string{"amount", "500", "lnbc1"}), session, agentNow); err != nil {
155 t.Fatalf("serve grant refused feedback: %v", err)
156 }
157 unknown := signed(t, agentSecret, 6001, agentNow, [][]string{{"e", strings.Repeat("c", 64)}, {"p", f.member}}, "")
158 expectRestricted(t, f.gate.Write(ctx, unknown, session, agentNow), "requests this relay holds")
159 expectRestricted(t, f.gate.Import(ctx, unknown, agentNow), "requests this relay holds")
160 // A note that is not a request cannot be answered, and an answer must
161 // match the request's kind and author.
162 note := signed(t, jobMemberSecret, 1, agentNow, nil, "not a job")
163 if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {
164 _, err := storage.SaveTx(ctx, tx, note, storage.SaveOptions{Now: agentNow})
165 return err
166 }); err != nil {
167 t.Fatal(err)
168 }
169 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 6001, agentNow, [][]string{{"e", note.ID}, {"p", f.member}}, ""), session, agentNow), "requests this relay holds")
170 expectError(t, f.gate.Write(ctx, answer(6002), session, agentNow), "invalid:", "does not answer")
171 expectError(t, f.gate.Write(ctx, signed(t, agentSecret, 6001, agentNow, [][]string{{"e", request.ID}, {"p", f.owner}}, ""), session, agentNow), "invalid:", "name the requester")
172
173 // both: requests and answers.
174 f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"jobs", "both"}))
175 if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow); err != nil {
176 t.Fatalf("both grant refused a request: %v", err)
177 }
178 if err := f.gate.Write(ctx, answer(6001), session, agentNow); err != nil {
179 t.Fatalf("both grant refused a result: %v", err)
180 }
181 // A k tag still covers a single job kind, and answers from a k grant
182 // name a held request as well.
183 f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"k", "5001"}, []string{"k", "6001"}))
184 if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow); err != nil {
185 t.Fatalf("k grant refused a request: %v", err)
186 }
187 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 5002, agentNow, nil, ""), session, agentNow), "kind 5002")
188 if err := f.gate.Write(ctx, answer(6001), session, agentNow); err != nil {
189 t.Fatalf("k grant refused a result: %v", err)
190 }
191 expectRestricted(t, f.gate.Write(ctx, unknown, session, agentNow), "requests this relay holds")
192 // The jobs tag is bounded.
193 if _, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "all"}), ""), agentNow); err == nil || !strings.Contains(err.Error(), "request, serve or both") {
194 t.Fatalf("jobs tag accepted an unknown value: %v", err)
195 }
196 grant, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "serve"}), ""), agentNow)
197 if err != nil || grant.Scope.Jobs != community.AgentJobsServe || !grant.ServesJobs() || grant.RequestsJobs() {
198 t.Fatalf("serve grant parsed as %+v: %v", grant.Scope, err)
199 }
200}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs