1package gates23import (4 "context"5 "database/sql"6 "encoding/json"7 "strings"8 "testing"910 "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)1617const jobMemberSecret = "0000000000000000000000000000000000000000000000000000000000000005"1819// jobFixture adds a human member who requests jobs to the agent fixture.20type jobFixture struct {21 agentFixture22 member string23}2425func 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}3536// request publishes a job request from the member and stores it, so37// 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 err48 }); err != nil {49 t.Fatal(err)50 }51 return e52}5354func 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}6061func 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 string68 kind int69 tags [][]string70 fragment string71 }{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 request90 // 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}103104func 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 on119 // import alike.120 p := f.gate.cfg.Policy()121 p.Features.Jobs = false122 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}129130func 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 }139140 // 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")147148 // 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 must161 // 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 err166 }); 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")172173 // 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 grant182 // 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}