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// jobRoom is the fixture's slug room, which every member may write to.20const jobRoom = "general"2122// jobFixture adds a human member who requests jobs to the agent fixture.23type jobFixture struct {24 agentFixture25 member string26}2728func newJobFixture(t *testing.T) jobFixture {29 t.Helper()30 f := newAgentFixture(t)31 member, _ := event.PublicKey(jobMemberSecret)32 raw, _ := json.Marshal(member)33 if _, err := f.community.Execute(context.Background(), f.owner, "setmember", []json.RawMessage{raw}); err != nil {34 t.Fatal(err)35 }36 return jobFixture{agentFixture: f, member: member}37}3839// request publishes a job request from the member asking the given keys40// and stores it, so answers can name a request the relay holds.41func (f jobFixture) request(t *testing.T, createdAt int64, asked ...string) event.Event {42 t.Helper()43 ctx := context.Background()44 tags := [][]string{{"h", jobRoom}, {"subject", "Summarize the notes"}}45 for _, key := range asked {46 tags = append(tags, []string{"p", key})47 }48 e := signed(t, jobMemberSecret, event.KIND_JOB_REQUEST, createdAt, tags, "Summarize this week's notes.")49 if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {50 t.Fatalf("member request rejected: %v", err)51 }52 f.save(t, e)53 return e54}5556func (f jobFixture) save(t *testing.T, e event.Event) {57 t.Helper()58 ctx := context.Background()59 if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {60 _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: agentNow})61 return err62 }); err != nil {63 t.Fatal(err)64 }65}6667func expectError(t *testing.T, err error, prefix, fragment string) {68 t.Helper()69 if err == nil || !strings.HasPrefix(err.Error(), prefix) || !strings.Contains(err.Error(), fragment) {70 t.Fatalf("want %s error containing %q, got %v", prefix, fragment, err)71 }72}7374func TestJobShapeRefusesMalformedEvents(t *testing.T) {75 f := newJobFixture(t)76 ctx := context.Background()77 session := relay.Session{PubKeys: []string{f.member}}78 request := f.request(t, agentNow, f.member)79 cases := []struct {80 name string81 kind int82 tags [][]string83 fragment string84 }{85 {"request without a room", event.KIND_JOB_REQUEST, [][]string{{"p", f.member}}, "h tag"},86 {"request with a bad room", event.KIND_JOB_REQUEST, [][]string{{"h", "Not A Room"}, {"p", f.member}}, "h tag"},87 {"request without an asked key", event.KIND_JOB_REQUEST, [][]string{{"h", jobRoom}}, "at least one asked key"},88 {"request with a bad asked key", event.KIND_JOB_REQUEST, [][]string{{"h", jobRoom}, {"p", "abc"}}, "hex public keys"},89 {"request with a bad root", event.KIND_JOB_REQUEST, [][]string{{"h", jobRoom}, {"p", f.member}, {"e", "abc", "", "root"}}, "thread root"},90 {"accepted without a request", event.KIND_JOB_ACCEPTED, [][]string{{"h", jobRoom}, {"p", f.member}}, "e tag"},91 {"progress without a requester", event.KIND_JOB_PROGRESS, [][]string{{"h", jobRoom}, {"e", request.ID}}, "p tag"},92 {"result without a room", event.KIND_JOB_RESULT, [][]string{{"e", request.ID}, {"p", f.member}}, "h tag"},93 {"error with a bad room", event.KIND_JOB_ERROR, [][]string{{"h", "Not A Room"}, {"e", request.ID}, {"p", f.member}}, "h tag"},94 {"cancel without a request", event.KIND_JOB_CANCEL, [][]string{{"h", jobRoom}}, "e tag"},95 {"cancel without a room", event.KIND_JOB_CANCEL, [][]string{{"e", request.ID}}, "h tag"},96 {"cancel with a bad assignee", event.KIND_JOB_CANCEL, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", "abc"}}, "p tag"},97 }98 for _, tc := range cases {99 err := f.gate.Write(ctx, signed(t, jobMemberSecret, tc.kind, agentNow, tc.tags, ""), session, agentNow)100 expectError(t, err, "invalid:", tc.fragment)101 }102 // Well-formed answers from an asked member pass, with artifact103 // references on a result and a cancel from the requester.104 for _, e := range []event.Event{105 signed(t, jobMemberSecret, event.KIND_JOB_ACCEPTED, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, ""),106 signed(t, jobMemberSecret, event.KIND_JOB_PROGRESS, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, "halfway"),107 signed(t, jobMemberSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}, {"e", strings.Repeat("a", 64)}, {"a", "30818:" + f.member + ":notes"}, {"r", "https://example.com/report"}}, "output"),108 signed(t, jobMemberSecret, event.KIND_JOB_ERROR, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, "no such input"),109 signed(t, jobMemberSecret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, ""),110 } {111 if err := f.gate.Write(ctx, e, session, agentNow); err != nil {112 t.Fatalf("well-formed kind %d rejected: %v", e.Kind, err)113 }114 }115 // Import applies the same shape rules.116 expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, event.KIND_JOB_PROGRESS, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}}, ""), agentNow), "invalid:", "p tag")117}118119func TestJobAnswersFollowTheHeldRequest(t *testing.T) {120 f := newJobFixture(t)121 ctx := context.Background()122 session := relay.Session{PubKeys: []string{f.member}}123 other, _ := event.PublicKey(agentOtherSecret)124 // The owner is a member who was not asked; the member was.125 request := f.request(t, agentNow, f.member)126 answer := func(secret string, kind int, tags ...[]string) event.Event {127 return signed(t, secret, kind, agentNow, append([][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, tags...), "output")128 }129 // No answers to requests the relay does not hold, from members either.130 elsewhere := signed(t, jobMemberSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", strings.Repeat("a", 64)}, {"p", other}}, "elsewhere")131 expectError(t, f.gate.Write(ctx, elsewhere, session, agentNow), "restricted:", "request this relay holds")132 expectError(t, f.gate.Import(ctx, elsewhere, agentNow), "restricted:", "request this relay holds")133 // A note that is not a request cannot be answered.134 note := signed(t, jobMemberSecret, 1, agentNow, nil, "not a job")135 f.save(t, note)136 expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", note.ID}, {"p", f.member}}, "x"), session, agentNow), "restricted:", "request this relay holds")137 // The p tag names the requester and the h tag keeps the room.138 expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.owner}}, "x"), session, agentNow), "invalid:", "name the requester")139 expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_PROGRESS, agentNow, [][]string{{"h", "other-room"}, {"e", request.ID}, {"p", f.member}}, "x"), session, agentNow), "invalid:", "room")140 // Only a key the request asked may answer it.141 expectError(t, f.gate.Write(ctx, answer(agentOwnerSecret, event.KIND_JOB_PROGRESS), relay.Session{PubKeys: []string{f.owner}}, agentNow), "restricted:", "key the request asked")142 if err := f.gate.Write(ctx, answer(jobMemberSecret, event.KIND_JOB_PROGRESS), session, agentNow); err != nil {143 t.Fatalf("asked member's progress rejected: %v", err)144 }145 // Only the requester may cancel.146 cancel := func(secret string) event.Event {147 return signed(t, secret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}}, "")148 }149 expectError(t, f.gate.Write(ctx, cancel(agentOwnerSecret), relay.Session{PubKeys: []string{f.owner}}, agentNow), "restricted:", "only the requester")150 if err := f.gate.Write(ctx, cancel(jobMemberSecret), session, agentNow); err != nil {151 t.Fatalf("requester's cancel rejected: %v", err)152 }153 expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", "other-room"}, {"e", request.ID}}, ""), session, agentNow), "invalid:", "room")154}155156func TestJobPolicyAndMembership(t *testing.T) {157 f := newJobFixture(t)158 ctx := context.Background()159 other, _ := event.PublicKey(agentOtherSecret)160 request := signed(t, agentOtherSecret, event.KIND_JOB_REQUEST, agentNow, [][]string{{"h", jobRoom}, {"p", f.member}}, "hello")161 // The relay writes openly, but job kinds are for members and agents.162 expectError(t, f.gate.Write(ctx, request, relay.Session{PubKeys: []string{other}}, agentNow), "restricted:", "members and agents")163 if err := f.gate.Write(ctx, signed(t, agentOtherSecret, 1, agentNow, nil, "note"), relay.Session{PubKeys: []string{other}}, agentNow); err != nil {164 t.Fatalf("open relay refused a note: %v", err)165 }166 f.request(t, agentNow, f.member)167 if err := f.gate.Write(ctx, signed(t, agentOwnerSecret, event.KIND_JOB_REQUEST, agentNow, [][]string{{"h", jobRoom}, {"p", f.member}}, "owner asks"), relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {168 t.Fatalf("owner request rejected: %v", err)169 }170 // Switching long tasks off refuses every job kind, for members and on171 // import alike.172 p := f.gate.cfg.Policy()173 p.Features.Jobs = false174 f.gate.cfg.Policy = func() policy.Policy { return p }175 expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_REQUEST, agentNow+1, [][]string{{"h", jobRoom}, {"p", f.member}}, "x"), relay.Session{PubKeys: []string{f.member}}, agentNow), "restricted:", "switched off")176 expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, event.KIND_JOB_CANCEL, agentNow+1, [][]string{{"h", jobRoom}, {"e", strings.Repeat("b", 64)}}, ""), agentNow), "restricted:", "switched off")177 if err := f.gate.Write(ctx, signed(t, jobMemberSecret, 1, agentNow+1, nil, "still fine"), relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {178 t.Fatalf("switching jobs off broke notes: %v", err)179 }180}181182func TestJobGrantsRequestServeAndBoth(t *testing.T) {183 f := newJobFixture(t)184 ctx := context.Background()185 session := relay.Session{PubKeys: []string{f.agent}}186 request := f.request(t, agentNow, f.agent)187 answer := func(kind int, extra ...[]string) event.Event {188 tags := append([][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, extra...)189 return signed(t, agentSecret, kind, agentNow, tags, "output")190 }191 ask := func(asked string) event.Event {192 return signed(t, agentSecret, event.KIND_JOB_REQUEST, agentNow, [][]string{{"h", jobRoom}, {"p", asked}}, "please")193 }194195 // request: 43001 and 43005 only.196 f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"room", jobRoom}, []string{"jobs", "request"}))197 if err := f.gate.Write(ctx, ask(f.member), session, agentNow); err != nil {198 t.Fatalf("request grant refused a request: %v", err)199 }200 own := ask(f.member)201 f.save(t, own)202 if err := f.gate.Write(ctx, signed(t, agentSecret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", jobRoom}, {"e", own.ID}}, ""), session, agentNow); err != nil {203 t.Fatalf("request grant refused a cancel: %v", err)204 }205 expectRestricted(t, f.gate.Write(ctx, answer(event.KIND_JOB_RESULT), session, agentNow), "kind 43004")206 expectRestricted(t, f.gate.Write(ctx, answer(event.KIND_JOB_PROGRESS), session, agentNow), "kind 43003")207208 // serve: answers to a held request that asked the agent; no requests209 // or cancels.210 f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"room", jobRoom}, []string{"jobs", "serve"}))211 expectRestricted(t, f.gate.Write(ctx, ask(f.member), session, agentNow), "kind 43001")212 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", jobRoom}, {"e", own.ID}}, ""), session, agentNow), "kind 43005")213 for _, kind := range []int{event.KIND_JOB_ACCEPTED, event.KIND_JOB_PROGRESS, event.KIND_JOB_RESULT, event.KIND_JOB_ERROR} {214 if err := f.gate.Write(ctx, answer(kind), session, agentNow); err != nil {215 t.Fatalf("serve grant refused kind %d: %v", kind, err)216 }217 }218 unknown := signed(t, agentSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", strings.Repeat("c", 64)}, {"p", f.member}}, "x")219 expectError(t, f.gate.Write(ctx, unknown, session, agentNow), "restricted:", "request this relay holds")220 expectError(t, f.gate.Import(ctx, unknown, agentNow), "restricted:", "request this relay holds")221 // A request that asked someone else cannot be answered by the agent.222 notAsked := f.request(t, agentNow+1, f.owner)223 expectError(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", notAsked.ID}, {"p", f.member}}, "x"), session, agentNow), "restricted:", "key the request asked")224 expectError(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.owner}}, "x"), session, agentNow), "invalid:", "name the requester")225226 // both: requests and answers.227 f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"room", jobRoom}, []string{"jobs", "both"}))228 if err := f.gate.Write(ctx, ask(f.member), session, agentNow); err != nil {229 t.Fatalf("both grant refused a request: %v", err)230 }231 if err := f.gate.Write(ctx, answer(event.KIND_JOB_RESULT), session, agentNow); err != nil {232 t.Fatalf("both grant refused a result: %v", err)233 }234 // A k tag still covers a single job kind, and answers from a k grant235 // name a held request as well.236 f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"room", jobRoom}, []string{"k", "43001"}, []string{"k", "43004"}))237 if err := f.gate.Write(ctx, ask(f.member), session, agentNow); err != nil {238 t.Fatalf("k grant refused a request: %v", err)239 }240 expectRestricted(t, f.gate.Write(ctx, answer(event.KIND_JOB_PROGRESS), session, agentNow), "kind 43003")241 if err := f.gate.Write(ctx, answer(event.KIND_JOB_RESULT), session, agentNow); err != nil {242 t.Fatalf("k grant refused a result: %v", err)243 }244 expectError(t, f.gate.Write(ctx, unknown, session, agentNow), "restricted:", "request this relay holds")245 // The jobs tag is bounded.246 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") {247 t.Fatalf("jobs tag accepted an unknown value: %v", err)248 }249 grant, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "serve"}), ""), agentNow)250 if err != nil || grant.Scope.Jobs != community.AgentJobsServe || !grant.ServesJobs() || grant.RequestsJobs() {251 t.Fatalf("serve grant parsed as %+v: %v", grant.Scope, err)252 }253}