From 02107b8710814fa1c9f4e60c5d05e25913373293 Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 18 Aug 2026 15:55:38 +0900 Subject: [PATCH] spindle/xrpc,lexicons/spindle/mod,api/tangled: add ban endpoints for the instance operator Signed-off-by: dawn --- api/tangled/modban.go | 30 +++ api/tangled/moddefs.go | 15 ++ api/tangled/modgetBan.go | 35 +++ api/tangled/modlistBans.go | 30 +++ api/tangled/modunban.go | 30 +++ lexicons/spindle/mod/ban.json | 31 +++ lexicons/spindle/mod/defs.json | 26 +++ lexicons/spindle/mod/getBan.json | 39 ++++ lexicons/spindle/mod/listBans.json | 29 +++ lexicons/spindle/mod/unban.json | 34 +++ spindle/server.go | 1 + spindle/xrpc/ban.go | 191 +++++++++++++++ spindle/xrpc/ban_test.go | 363 +++++++++++++++++++++++++++++ spindle/xrpc/xrpc.go | 10 + 14 files changed, 864 insertions(+) create mode 100644 api/tangled/modban.go create mode 100644 api/tangled/moddefs.go create mode 100644 api/tangled/modgetBan.go create mode 100644 api/tangled/modlistBans.go create mode 100644 api/tangled/modunban.go create mode 100644 lexicons/spindle/mod/ban.json create mode 100644 lexicons/spindle/mod/defs.json create mode 100644 lexicons/spindle/mod/getBan.json create mode 100644 lexicons/spindle/mod/listBans.json create mode 100644 lexicons/spindle/mod/unban.json create mode 100644 spindle/xrpc/ban.go create mode 100644 spindle/xrpc/ban_test.go diff --git a/api/tangled/modban.go b/api/tangled/modban.go new file mode 100644 index 00000000..a398fcfc --- /dev/null +++ b/api/tangled/modban.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.spindle.mod.ban + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + SpindleModBanNSID = "sh.tangled.spindle.mod.ban" +) + +// SpindleModBan_Input is the input argument to a sh.tangled.spindle.mod.ban call. +type SpindleModBan_Input struct { + // did: DID of the repository or owner to ban. + Did string `json:"did" cborgen:"did"` +} + +// SpindleModBan calls the XRPC method "sh.tangled.spindle.mod.ban". +func SpindleModBan(ctx context.Context, c util.LexClient, input *SpindleModBan_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.spindle.mod.ban", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/moddefs.go b/api/tangled/moddefs.go new file mode 100644 index 00000000..d89ee009 --- /dev/null +++ b/api/tangled/moddefs.go @@ -0,0 +1,15 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.spindle.mod.defs + +const () + +// SpindleModDefs_Ban is a "ban" in the sh.tangled.spindle.mod.defs schema. +type SpindleModDefs_Ban struct { + // createdAt: Timestamp when the ban was created. + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // did: DID of the banned repository or owner. + Did string `json:"did" cborgen:"did"` +} diff --git a/api/tangled/modgetBan.go b/api/tangled/modgetBan.go new file mode 100644 index 00000000..abf6ece4 --- /dev/null +++ b/api/tangled/modgetBan.go @@ -0,0 +1,35 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.spindle.mod.getBan + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + SpindleModGetBanNSID = "sh.tangled.spindle.mod.getBan" +) + +// SpindleModGetBan_Output is the output of a sh.tangled.spindle.mod.getBan call. +type SpindleModGetBan_Output struct { + Ban *SpindleModDefs_Ban `json:"ban" cborgen:"ban"` +} + +// SpindleModGetBan calls the XRPC method "sh.tangled.spindle.mod.getBan". +// +// did: DID of the repository or owner. +func SpindleModGetBan(ctx context.Context, c util.LexClient, did string) (*SpindleModGetBan_Output, error) { + var out SpindleModGetBan_Output + + params := map[string]interface{}{} + params["did"] = did + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.spindle.mod.getBan", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/modlistBans.go b/api/tangled/modlistBans.go new file mode 100644 index 00000000..1a6126e1 --- /dev/null +++ b/api/tangled/modlistBans.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.spindle.mod.listBans + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + SpindleModListBansNSID = "sh.tangled.spindle.mod.listBans" +) + +// SpindleModListBans_Output is the output of a sh.tangled.spindle.mod.listBans call. +type SpindleModListBans_Output struct { + Bans []*SpindleModDefs_Ban `json:"bans" cborgen:"bans"` +} + +// SpindleModListBans calls the XRPC method "sh.tangled.spindle.mod.listBans". +func SpindleModListBans(ctx context.Context, c util.LexClient) (*SpindleModListBans_Output, error) { + var out SpindleModListBans_Output + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.spindle.mod.listBans", nil, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/modunban.go b/api/tangled/modunban.go new file mode 100644 index 00000000..07451016 --- /dev/null +++ b/api/tangled/modunban.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.spindle.mod.unban + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + SpindleModUnbanNSID = "sh.tangled.spindle.mod.unban" +) + +// SpindleModUnban_Input is the input argument to a sh.tangled.spindle.mod.unban call. +type SpindleModUnban_Input struct { + // did: DID of the repository or owner to unban. + Did string `json:"did" cborgen:"did"` +} + +// SpindleModUnban calls the XRPC method "sh.tangled.spindle.mod.unban". +func SpindleModUnban(ctx context.Context, c util.LexClient, input *SpindleModUnban_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.spindle.mod.unban", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/lexicons/spindle/mod/ban.json b/lexicons/spindle/mod/ban.json new file mode 100644 index 00000000..e6bc6c33 --- /dev/null +++ b/lexicons/spindle/mod/ban.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "sh.tangled.spindle.mod.ban", + "defs": { + "main": { + "type": "procedure", + "description": "ban a repository or account owner and immediately wipe matching spindle state", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of the repository or account owner to ban" + } + } + } + }, + "errors": [ + { + "name": "InvalidDid" + } + ] + } + } +} diff --git a/lexicons/spindle/mod/defs.json b/lexicons/spindle/mod/defs.json new file mode 100644 index 00000000..c23e567b --- /dev/null +++ b/lexicons/spindle/mod/defs.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "sh.tangled.spindle.mod.defs", + "defs": { + "ban": { + "description": "an active ban for a repository or account owner", + "type": "object", + "required": [ + "did", + "createdAt" + ], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of the banned subject, either a repository or its owner" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "time at which the ban was created" + } + } + } + } +} diff --git a/lexicons/spindle/mod/getBan.json b/lexicons/spindle/mod/getBan.json new file mode 100644 index 00000000..c580a8d7 --- /dev/null +++ b/lexicons/spindle/mod/getBan.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "sh.tangled.spindle.mod.getBan", + "defs": { + "main": { + "type": "query", + "description": "return the active ban for a repository or account owner DID", + "parameters": { + "type": "params", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of the repository or account owner to look up" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ban" + ], + "properties": { + "ban": { + "description": "active ban matching the requested DID", + "type": "ref", + "ref": "sh.tangled.spindle.mod.defs#ban" + } + } + } + } + } + } +} diff --git a/lexicons/spindle/mod/listBans.json b/lexicons/spindle/mod/listBans.json new file mode 100644 index 00000000..693159e9 --- /dev/null +++ b/lexicons/spindle/mod/listBans.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "sh.tangled.spindle.mod.listBans", + "defs": { + "main": { + "type": "query", + "description": "list all active repository and account-owner bans", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "bans" + ], + "properties": { + "bans": { + "description": "active bans ordered by subject DID", + "type": "array", + "items": { + "type": "ref", + "ref": "sh.tangled.spindle.mod.defs#ban" + } + } + } + } + } + } + } +} diff --git a/lexicons/spindle/mod/unban.json b/lexicons/spindle/mod/unban.json new file mode 100644 index 00000000..4b68e976 --- /dev/null +++ b/lexicons/spindle/mod/unban.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "sh.tangled.spindle.mod.unban", + "defs": { + "main": { + "type": "procedure", + "description": "remove an existing ban for a repository or account owner", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of the repository or account owner to unban" + } + } + } + }, + "errors": [ + { + "name": "InvalidDid" + }, + { + "name": "BanNotFound" + } + ] + } + } +} diff --git a/spindle/server.go b/spindle/server.go index 0bc7816d..874542d0 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -739,6 +739,7 @@ func (s *Spindle) XrpcRouter() http.Handler { ServiceAuth: serviceAuth, Trigger: s, QuotaStore: db.NewQuotaStore(s.db, quota.Defaults{}), + Wiper: s, } return x.Router() diff --git a/spindle/xrpc/ban.go b/spindle/xrpc/ban.go new file mode 100644 index 00000000..65663ee4 --- /dev/null +++ b/spindle/xrpc/ban.go @@ -0,0 +1,191 @@ +package xrpc + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/bluesky-social/indigo/atproto/syntax" + "net/http" + "tangled.org/core/api/tangled" + "tangled.org/core/spindle/db" + xrpcerr "tangled.org/core/xrpc/errors" +) + +func (x *Xrpc) Ban(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e xrpcerr.XrpcError, status int) { + l.ErrorContext(r.Context(), "ban add failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, status) + } + + _, errObj, status := x.checkSpindleOwner(r) + if errObj != nil { + fail(*errObj, status) + return + } + + var data tangled.SpindleModBan_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err), http.StatusBadRequest) + return + } + + did, err := syntax.ParseDID(data.Did) + if err != nil { + fail(xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidDid"), + xrpcerr.WithMessage(fmt.Sprintf("invalid DID: %s", data.Did)), + ), http.StatusBadRequest) + return + } + + ban := db.BanEntry{ + SubjectDid: did, + } + if err := x.Db.PutBan(ban); err != nil { + fail(xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + if x.Wiper == nil { + fail(xrpcerr.GenericError(errors.New("purger unavailable")), http.StatusInternalServerError) + return + } + + // wipe both possible ban roles + err = x.Wiper.WipeRepo(r.Context(), did, "banned") + if oerr := x.Wiper.WipeOwner(r.Context(), did, "banned"); oerr != nil { + err = errors.Join(err, oerr) + } + if err != nil { + l.ErrorContext(r.Context(), "failed to wipe banned subject", "did", did.String(), "err", err) + fail(xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) Unban(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e xrpcerr.XrpcError, status int) { + l.ErrorContext(r.Context(), "ban remove failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, status) + } + + if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + fail(*errObj, status) + return + } + + var data tangled.SpindleModUnban_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err), http.StatusBadRequest) + return + } + + did, err := syntax.ParseDID(data.Did) + if err != nil { + fail(xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidDid"), + xrpcerr.WithMessage(fmt.Sprintf("invalid DID: %s", data.Did)), + ), http.StatusBadRequest) + return + } + + removed, err := x.Db.DeleteBan(did) + if err != nil { + fail(xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + if !removed { + fail(xrpcerr.NewXrpcError( + xrpcerr.WithTag("BanNotFound"), + xrpcerr.WithMessage("ban not found"), + ), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) GetBan(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e xrpcerr.XrpcError, status int) { + l.ErrorContext(r.Context(), "ban get failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, status) + } + + if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + fail(*errObj, status) + return + } + + did, err := syntax.ParseDID(r.URL.Query().Get("did")) + if err != nil { + fail(xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidDid"), + xrpcerr.WithMessage(fmt.Sprintf("invalid DID: %s", r.URL.Query().Get("did"))), + ), http.StatusBadRequest) + return + } + + ban, err := x.Db.GetBan(did) + if err != nil { + fail(xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + if ban == nil { + fail(xrpcerr.NewXrpcError( + xrpcerr.WithTag("BanNotFound"), + xrpcerr.WithMessage("ban not found"), + ), http.StatusNotFound) + return + } + + out := tangled.SpindleModGetBan_Output{ + Ban: &tangled.SpindleModDefs_Ban{ + Did: ban.SubjectDid.String(), + CreatedAt: ban.CreatedAt, + }, + } + + if err := writeJson(w, http.StatusOK, out); err != nil { + fail(xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } +} + +func (x *Xrpc) ListBans(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e xrpcerr.XrpcError, status int) { + l.ErrorContext(r.Context(), "ban list failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, status) + } + + if _, errObj, status := x.checkSpindleOwner(r); errObj != nil { + fail(*errObj, status) + return + } + + bans, err := x.Db.BanList() + if err != nil { + fail(xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + out := tangled.SpindleModListBans_Output{ + Bans: make([]*tangled.SpindleModDefs_Ban, 0, len(bans)), + } + for _, b := range bans { + out.Bans = append(out.Bans, &tangled.SpindleModDefs_Ban{ + Did: b.SubjectDid.String(), + CreatedAt: b.CreatedAt, + }) + } + + if err := writeJson(w, http.StatusOK, out); err != nil { + fail(xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } +} diff --git a/spindle/xrpc/ban_test.go b/spindle/xrpc/ban_test.go new file mode 100644 index 00000000..f3c4ba02 --- /dev/null +++ b/spindle/xrpc/ban_test.go @@ -0,0 +1,363 @@ +package xrpc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/rbac" + "tangled.org/core/spindle/config" +) + +type fakeRepoWiper struct { + wipedRepos []syntax.DID + wipedOwners []syntax.DID + repoReasons []string + ownerReasons []string + err error +} + +func (f *fakeRepoWiper) WipeRepo(ctx context.Context, repoDid syntax.DID, reason string) error { + f.wipedRepos = append(f.wipedRepos, repoDid) + f.repoReasons = append(f.repoReasons, reason) + return f.err +} + +func (f *fakeRepoWiper) WipeOwner(ctx context.Context, ownerDid syntax.DID, reason string) error { + f.wipedOwners = append(f.wipedOwners, ownerDid) + f.ownerReasons = append(f.ownerReasons, reason) + return f.err +} + +func setupBanTestXrpc(t *testing.T) (*Xrpc, *fakeRepoWiper, syntax.DID, syntax.DID) { + t.Helper() + d, e := newTestXrpcDB(t) + + ownerDid := syntax.DID("did:plc:spindleowner") + nonOwnerDid := syntax.DID("did:plc:otheruser") + + if err := e.AddSpindle(rbac.ThisServer); err != nil { + t.Fatalf("AddSpindle: %v", err) + } + if err := e.AddSpindleOwner(rbac.ThisServer, ownerDid.String()); err != nil { + t.Fatalf("AddSpindleOwner: %v", err) + } + + wiper := &fakeRepoWiper{} + x := &Xrpc{ + Logger: slog.Default(), + Db: d, + Enforcer: e, + Config: &config.Config{}, + Wiper: wiper, + } + + return x, wiper, ownerDid, nonOwnerDid +} + +func sendBan(x *Xrpc, actor syntax.DID, input tangled.SpindleModBan_Input) (*httptest.ResponseRecorder, int) { + body, _ := json.Marshal(input) + req := httptest.NewRequest(http.MethodPost, "/"+tangled.SpindleModBanNSID, bytes.NewReader(body)) + if actor != "" { + ctx := context.WithValue(req.Context(), ActorDid, actor) + req = req.WithContext(ctx) + } + w := httptest.NewRecorder() + x.Ban(w, req) + return w, w.Code +} + +func sendUnban(x *Xrpc, actor syntax.DID, input tangled.SpindleModUnban_Input) (*httptest.ResponseRecorder, int) { + body, _ := json.Marshal(input) + req := httptest.NewRequest(http.MethodPost, "/"+tangled.SpindleModUnbanNSID, bytes.NewReader(body)) + if actor != "" { + ctx := context.WithValue(req.Context(), ActorDid, actor) + req = req.WithContext(ctx) + } + w := httptest.NewRecorder() + x.Unban(w, req) + return w, w.Code +} + +func sendListBans(x *Xrpc, actor syntax.DID) (*httptest.ResponseRecorder, int) { + req := httptest.NewRequest(http.MethodGet, "/"+tangled.SpindleModListBansNSID, nil) + if actor != "" { + ctx := context.WithValue(req.Context(), ActorDid, actor) + req = req.WithContext(ctx) + } + w := httptest.NewRecorder() + x.ListBans(w, req) + return w, w.Code +} + +func sendGetBan(x *Xrpc, actor syntax.DID, query string) (*httptest.ResponseRecorder, int) { + path := "/" + tangled.SpindleModGetBanNSID + if query != "" { + path += "?" + query + } + req := httptest.NewRequest(http.MethodGet, path, nil) + if actor != "" { + ctx := context.WithValue(req.Context(), ActorDid, actor) + req = req.WithContext(ctx) + } + w := httptest.NewRecorder() + x.GetBan(w, req) + return w, w.Code +} + +func TestBan_OwnerAuth(t *testing.T) { + x, _, ownerDid, nonOwnerDid := setupBanTestXrpc(t) + + addInput := tangled.SpindleModBan_Input{ + Did: "did:plc:targetrepo", + } + + w, code := sendBan(x, nonOwnerDid, addInput) + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 for non-owner ban add, got %d", code) + } + + w, code = sendBan(x, "", addInput) + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 for missing actor ban add, got %d", code) + } + + w, code = sendBan(x, ownerDid, addInput) + if code != http.StatusOK { + t.Fatalf("expected 200 for owner ban add, got %d (body: %s)", code, w.Body.String()) + } + + w, code = sendListBans(x, nonOwnerDid) + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 for non-owner ban list, got %d", code) + } + + w, code = sendListBans(x, "") + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 for missing actor ban list, got %d", code) + } + + w, code = sendListBans(x, ownerDid) + if code != http.StatusOK { + t.Fatalf("expected 200 for owner ban list, got %d", code) + } + + removeInput := tangled.SpindleModUnban_Input{ + Did: "did:plc:targetrepo", + } + + w, code = sendUnban(x, nonOwnerDid, removeInput) + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 for non-owner ban remove, got %d", code) + } + + w, code = sendUnban(x, "", removeInput) + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 for missing actor ban remove, got %d", code) + } + + w, code = sendUnban(x, ownerDid, removeInput) + if code != http.StatusOK { + t.Fatalf("expected 200 for owner ban remove, got %d", code) + } +} + +func TestBan_ValidationErrors(t *testing.T) { + x, wiper, ownerDid, _ := setupBanTestXrpc(t) + + tests := []struct { + name string + input tangled.SpindleModBan_Input + }{ + { + name: "invalid did", + input: tangled.SpindleModBan_Input{ + Did: "notadid", + }, + }, + { + name: "empty did", + input: tangled.SpindleModBan_Input{ + Did: "", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, code := sendBan(x, ownerDid, tc.input) + if code != http.StatusBadRequest { + t.Fatalf("expected 400 for %s, got %d (body: %s)", tc.name, code, w.Body.String()) + } + bans, err := x.Db.BanList() + if err != nil { + t.Fatalf("BanList: %v", err) + } + if len(bans) != 0 { + t.Fatalf("expected no ban row created for %s, got %+v", tc.name, bans) + } + }) + } + + removeTests := []struct { + name string + input tangled.SpindleModUnban_Input + }{ + { + name: "invalid did remove", + input: tangled.SpindleModUnban_Input{ + Did: "notadid", + }, + }, + { + name: "ban not found", + input: tangled.SpindleModUnban_Input{ + Did: "did:plc:nonexistent", + }, + }, + } + + for _, tc := range removeTests { + t.Run(tc.name, func(t *testing.T) { + w, code := sendUnban(x, ownerDid, tc.input) + if code != http.StatusBadRequest { + t.Fatalf("expected 400 for %s, got %d (body: %s)", tc.name, code, w.Body.String()) + } + }) + } + + wiper.err = errors.New("wipe network error") + _, code := sendBan(x, ownerDid, tangled.SpindleModBan_Input{ + Did: "did:plc:errorrepo", + }) + if code != http.StatusInternalServerError { + t.Fatalf("expected 500 on purge failure, got %d", code) + } +} + +func TestBan_RoundTrip(t *testing.T) { + x, wiper, ownerDid, _ := setupBanTestXrpc(t) + + w, code := sendBan(x, ownerDid, tangled.SpindleModBan_Input{ + Did: "did:plc:repo123", + }) + if code != http.StatusOK { + t.Fatalf("expected 200 for repo ban add, got %d", code) + } + if len(wiper.wipedRepos) != 1 || wiper.wipedRepos[0] != "did:plc:repo123" { + t.Fatalf("expected repo wipe called for did:plc:repo123, got %+v", wiper.wipedRepos) + } + if len(wiper.wipedOwners) != 1 || wiper.wipedOwners[0] != "did:plc:repo123" { + t.Fatalf("expected owner wipe called for did:plc:repo123, got %+v", wiper.wipedOwners) + } + if len(wiper.repoReasons) != 1 || wiper.repoReasons[0] != "banned" { + t.Fatalf("expected wipe reason 'banned', got %v", wiper.repoReasons) + } + + w, code = sendBan(x, ownerDid, tangled.SpindleModBan_Input{ + Did: "did:plc:badactor", + }) + if code != http.StatusOK { + t.Fatalf("expected 200 for owner ban add, got %d", code) + } + if len(wiper.wipedOwners) != 2 || wiper.wipedOwners[1] != "did:plc:badactor" { + t.Fatalf("expected owner wipe called for did:plc:badactor, got %+v", wiper.wipedOwners) + } + if len(wiper.ownerReasons) != 2 || wiper.ownerReasons[0] != "banned" || wiper.ownerReasons[1] != "banned" { + t.Fatalf("expected wipe reason 'banned' for both wipes, got %v", wiper.ownerReasons) + } + + w, code = sendListBans(x, ownerDid) + if code != http.StatusOK { + t.Fatalf("expected 200 for ban list, got %d", code) + } + var listOut tangled.SpindleModListBans_Output + if err := json.Unmarshal(w.Body.Bytes(), &listOut); err != nil { + t.Fatalf("failed to decode ban list output: %v", err) + } + if len(listOut.Bans) != 2 { + t.Fatalf("expected 2 bans in list, got %d", len(listOut.Bans)) + } + + foundRepo := false + foundOwner := false + for _, b := range listOut.Bans { + if b.Did == "did:plc:repo123" && b.CreatedAt != "" { + foundRepo = true + } + if b.Did == "did:plc:badactor" && b.CreatedAt != "" { + foundOwner = true + } + } + if !foundRepo || !foundOwner { + t.Fatalf("expected both repo and owner bans in list, got %+v", listOut.Bans) + } + + w, code = sendUnban(x, ownerDid, tangled.SpindleModUnban_Input{ + Did: "did:plc:repo123", + }) + if code != http.StatusOK { + t.Fatalf("expected 200 for repo ban remove, got %d", code) + } + + w, code = sendUnban(x, ownerDid, tangled.SpindleModUnban_Input{ + Did: "did:plc:repo123", + }) + if code != http.StatusBadRequest { + t.Fatalf("expected 400 for duplicate remove, got %d", code) + } + + w, code = sendListBans(x, ownerDid) + if code != http.StatusOK { + t.Fatalf("expected 200 for ban list after remove, got %d", code) + } + var afterRemoveOut tangled.SpindleModListBans_Output + if err := json.Unmarshal(w.Body.Bytes(), &afterRemoveOut); err != nil { + t.Fatalf("failed to decode ban list output: %v", err) + } + if len(afterRemoveOut.Bans) != 1 || afterRemoveOut.Bans[0].Did != "did:plc:badactor" { + t.Fatalf("expected only owner ban remaining, got %+v", afterRemoveOut.Bans) + } +} + +func TestGetBan(t *testing.T) { + x, _, ownerDid, nonOwnerDid := setupBanTestXrpc(t) + banned := syntax.DID("did:plc:bannedone") + + if _, code := sendGetBan(x, ownerDid, "did="+banned.String()); code != http.StatusNotFound { + t.Fatalf("expected 404 before the ban, got %d", code) + } + + if _, code := sendBan(x, ownerDid, tangled.SpindleModBan_Input{Did: banned.String()}); code != http.StatusOK { + t.Fatalf("ban add: expected 200, got %d", code) + } + + w, code := sendGetBan(x, ownerDid, "did="+banned.String()) + if code != http.StatusOK { + t.Fatalf("expected 200 after the ban, got %d", code) + } + var out tangled.SpindleModGetBan_Output + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.Ban == nil || out.Ban.Did != banned.String() { + t.Fatalf("expected the banned did echoed back, got %+v", out.Ban) + } + if out.Ban.CreatedAt == "" { + t.Fatal("expected a ban timestamp") + } + + if _, code := sendGetBan(x, ownerDid, "did=notadid"); code != http.StatusBadRequest { + t.Fatalf("expected 400 for a malformed did, got %d", code) + } + if _, code := sendGetBan(x, nonOwnerDid, "did="+banned.String()); code == http.StatusOK { + t.Fatal("expected a non-owner read to be refused") + } +} diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go index 5bddf7d7..48155a01 100644 --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -44,6 +44,11 @@ type PipelineTrigger interface { DescribeWorkflowDefinition(ctx context.Context, repoDid syntax.DID, sha string, sourceRepo syntax.DID) (*tangled.CiDescribeWorkflowDefinition_Output, error) } +type RepoWiper interface { + WipeRepo(ctx context.Context, repoDid syntax.DID, reason string) error + WipeOwner(ctx context.Context, ownerDid syntax.DID, reason string) error +} + type PullContext struct { IsPullRequest bool Pull syntax.ATURI @@ -64,6 +69,7 @@ type Xrpc struct { ServiceAuth *serviceauth.ServiceAuth Trigger PipelineTrigger QuotaStore quota.Store + Wiper RepoWiper } func (x *Xrpc) Router() http.Handler { @@ -82,6 +88,10 @@ func (x *Xrpc) Router() http.Handler { r.Get("/"+tangled.SpindleQuotaGetNSID, x.GetLimit) r.Get("/"+tangled.SpindleQuotaListNSID, x.ListLimits) r.Get("/"+tangled.SpindleQuotaUsageNSID, x.GetUsage) + r.Post("/"+tangled.SpindleModBanNSID, x.Ban) + r.Post("/"+tangled.SpindleModUnbanNSID, x.Unban) + r.Get("/"+tangled.SpindleModGetBanNSID, x.GetBan) + r.Get("/"+tangled.SpindleModListBansNSID, x.ListBans) }) // service query endpoints (no auth required) -- 2.51.2