feat(assets): add editable company and task scopes
This commit is contained in:
1 parent
4392b4fee1
commit
31dc9e7446
20 files changed
+4186
-783
No files matched your search
+167
-53
@@ -2,12 +2,58 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// companyScopeInputs accepts both the new [{kind,value}] contract and the
|
||||
// historical ["example.com","203.0.113.10"] contract.
|
||||
type companyScopeInputs []db.ScopeInput
|
||||
|
||||
const maxCompanyMutationBodyBytes = 2 << 20
|
||||
|
||||
func decodeCompanyMutationRequest(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxCompanyMutationBodyBytes)
|
||||
if err := json.NewDecoder(r.Body).Decode(value); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (items *companyScopeInputs) UnmarshalJSON(data []byte) error {
|
||||
var rawItems []json.RawMessage
|
||||
if err := json.Unmarshal(data, &rawItems); err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]db.ScopeInput, 0, len(rawItems))
|
||||
for i, raw := range rawItems {
|
||||
var legacy string
|
||||
if err := json.Unmarshal(raw, &legacy); err == nil {
|
||||
out = append(out, db.ScopeInput{Value: legacy})
|
||||
continue
|
||||
}
|
||||
var structured db.ScopeInput
|
||||
if err := json.Unmarshal(raw, &structured); err != nil {
|
||||
return fmt.Errorf("scope[%d] must be a string or {kind,value}", i)
|
||||
}
|
||||
out = append(out, structured)
|
||||
}
|
||||
*items = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// assetStore returns the asset store.
|
||||
func (s *Server) assetStore() *db.AssetStore {
|
||||
if s.m.pg == nil {
|
||||
@@ -53,32 +99,45 @@ func (s *Server) createCompany(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Logo string `json:"logo"`
|
||||
Scope []string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
Logo string `json:"logo"`
|
||||
Scope companyScopeInputs `json:"scope"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "invalid JSON: "+err.Error())
|
||||
if !decodeCompanyMutationRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
writeErr(w, 400, "name required")
|
||||
return
|
||||
}
|
||||
id, created, err := cs.UpsertCompany(req.Name, req.Logo)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
if err := db.ValidateCompanyScopeInputBounds(req.Scope); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
out := map[string]any{"id": id, "created": created}
|
||||
if len(req.Scope) > 0 {
|
||||
added, skipped, invalid, errs := cs.AddScope(id, req.Scope, "api")
|
||||
out["scope_added"] = added
|
||||
out["scope_skipped"] = skipped
|
||||
out["scope_invalid"] = invalid
|
||||
if len(errs) > 0 {
|
||||
out["scope_errors"] = errs
|
||||
id, added, skipped, invalid, scopeErrs, err := cs.CreateCompanyWithScope(req.Name, req.Logo, req.Scope, "api")
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrCompanyNameConflict) {
|
||||
writeErr(w, http.StatusConflict, "企业名称已存在")
|
||||
return
|
||||
}
|
||||
var validationErr *db.CompanyScopeValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
writeErr(w, http.StatusBadRequest, validationErr.Error())
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": id,
|
||||
"created": true,
|
||||
"scope_added": added,
|
||||
"scope_skipped": skipped,
|
||||
"scope_invalid": invalid,
|
||||
}
|
||||
if len(scopeErrs) > 0 {
|
||||
out["scope_errors"] = scopeErrs
|
||||
}
|
||||
writeJSON(w, 201, out)
|
||||
}
|
||||
@@ -131,20 +190,37 @@ func (s *Server) addCompanyScope(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Scope []string `json:"scope"`
|
||||
Reason string `json:"reason"`
|
||||
Reset bool `json:"reset"` // if true, replace existing scope
|
||||
Scope companyScopeInputs `json:"scope"`
|
||||
Reason string `json:"reason"`
|
||||
Reset bool `json:"reset"` // if true, replace existing scope
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "invalid JSON: "+err.Error())
|
||||
if !decodeCompanyMutationRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := db.ValidateCompanyScopeInputBounds(req.Scope); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var added, skipped, invalid int
|
||||
var errs []string
|
||||
var mutationErr error
|
||||
if req.Reset {
|
||||
added, invalid, errs = cs.UpdateScope(id, req.Scope, req.Reason)
|
||||
added, invalid, errs, mutationErr = cs.UpdateScopeInputsChecked(id, req.Scope, req.Reason)
|
||||
} else {
|
||||
added, skipped, invalid, errs = cs.AddScope(id, req.Scope, req.Reason)
|
||||
added, skipped, invalid, errs, mutationErr = cs.AddScopeInputsChecked(id, req.Scope, req.Reason)
|
||||
}
|
||||
if mutationErr != nil {
|
||||
if errors.Is(mutationErr, db.ErrCompanyNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "company not found")
|
||||
return
|
||||
}
|
||||
var validationErr *db.CompanyScopeValidationError
|
||||
if errors.As(mutationErr, &validationErr) {
|
||||
writeErr(w, http.StatusBadRequest, validationErr.Error())
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, mutationErr.Error())
|
||||
return
|
||||
}
|
||||
out := map[string]any{
|
||||
"added": added,
|
||||
@@ -175,21 +251,32 @@ func (s *Server) deleteCompany(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DeleteAssets bool `json:"delete_assets"`
|
||||
}
|
||||
// Body is optional; ignore decode errors (e.g. empty body).
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
assetsDeleted := int64(0)
|
||||
if req.DeleteAssets {
|
||||
as := s.assetStore()
|
||||
if as != nil {
|
||||
assetsDeleted, err = as.DeleteByCompanyID(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
// The body is optional. Only an actual empty body is ignored; malformed or
|
||||
// trailing JSON is a client error.
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON: multiple values")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := cs.DeleteCompany(id); err != nil {
|
||||
|
||||
assetsDeleted, err := s.m.DeleteCompanyWithAssets(id, req.DeleteAssets)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrCompanyNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "company not found")
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -217,6 +304,11 @@ func (s *Server) reattribute(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /api/assets
|
||||
// =====================================================================
|
||||
|
||||
const (
|
||||
defaultAssetPageSize = 50
|
||||
maxAssetPageSize = 200
|
||||
)
|
||||
|
||||
func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
|
||||
as := s.assetStore()
|
||||
if as == nil {
|
||||
@@ -228,43 +320,56 @@ func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
limit = defaultAssetPageSize
|
||||
} else if limit > maxAssetPageSize {
|
||||
limit = maxAssetPageSize
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var assets []*db.Asset
|
||||
assets := []*db.Asset{}
|
||||
var err error
|
||||
// total is the full match count ignoring limit/offset — lets the UI paginate on
|
||||
// the server. For company/task scopes it falls back to the returned length.
|
||||
// total is the full match count ignoring limit/offset. Count first so an offset
|
||||
// beyond the last row can return an empty page without an expensive scan.
|
||||
total := 0
|
||||
|
||||
if dsl := q.Get("dsl"); dsl != "" {
|
||||
assets, err = as.QueryDSL(dsl, typ, limit, offset)
|
||||
if err == nil {
|
||||
total, _ = as.CountDSL(dsl, typ)
|
||||
if err := db.ValidateDSL(dsl); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
total, err = as.CountDSL(dsl, typ)
|
||||
if err == nil && offset < total {
|
||||
assets, err = as.QueryDSL(dsl, typ, limit, offset)
|
||||
}
|
||||
} else {
|
||||
companyID, _ := strconv.ParseInt(q.Get("company_id"), 10, 64)
|
||||
taskID, _ := strconv.ParseInt(q.Get("task_id"), 10, 64)
|
||||
switch {
|
||||
case companyID > 0:
|
||||
assets, err = as.QueryByCompany(companyID, typ, limit)
|
||||
total = len(assets)
|
||||
total, err = as.CountByCompany(companyID, typ)
|
||||
if err == nil && offset < total {
|
||||
assets, err = as.QueryByCompany(companyID, typ, limit, offset)
|
||||
}
|
||||
case taskID > 0:
|
||||
assets, err = as.QueryByTask(taskID, typ, limit)
|
||||
total = len(assets)
|
||||
total, err = as.CountByTask(taskID, typ)
|
||||
if err == nil && offset < total {
|
||||
assets, err = as.QueryByTask(taskID, typ, limit, offset)
|
||||
}
|
||||
default:
|
||||
if typ == "" {
|
||||
typ = "root_domain"
|
||||
}
|
||||
assets, err = as.QueryByType(typ, limit, offset)
|
||||
if err == nil {
|
||||
total, _ = as.CountByType(typ)
|
||||
total, err = as.CountByType(typ)
|
||||
if err == nil && offset < total {
|
||||
assets, err = as.QueryByType(typ, limit, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
@@ -284,7 +389,13 @@ func (s *Server) assetCounts(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 503, "database unavailable")
|
||||
return
|
||||
}
|
||||
counts, err := as.CountsByType()
|
||||
var counts map[string]int
|
||||
var err error
|
||||
if taskID, _ := strconv.ParseInt(r.URL.Query().Get("task_id"), 10, 64); taskID > 0 {
|
||||
counts, err = as.CountsByTypeForTask(taskID)
|
||||
} else {
|
||||
counts, err = as.CountsByType()
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -336,8 +447,8 @@ func (s *Server) insertAssets(w http.ResponseWriter, r *http.Request) {
|
||||
Assets []struct {
|
||||
Type string `json:"type"`
|
||||
// root_domain / subdomain
|
||||
Domain string `json:"domain"`
|
||||
ICP string `json:"icp"`
|
||||
Domain string `json:"domain"`
|
||||
ICP string `json:"icp"`
|
||||
RecordType string `json:"record_type"`
|
||||
RecordValue []string `json:"record_value"`
|
||||
// ip
|
||||
@@ -420,6 +531,9 @@ func (s *Server) insertAssets(w http.ResponseWriter, r *http.Request) {
|
||||
errs = append(errs, errEntry{Index: i, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
if req.TaskID > 0 {
|
||||
_ = as.SetTaskAssetSource(req.TaskID, id, "api", "通过资产 API 登记", nil)
|
||||
}
|
||||
results = append(results, result{Index: i, ID: id, Type: a.Type})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"results": results, "errors": errs})
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestCompanyScopeInputsAcceptStructuredAndLegacyRules(t *testing.T) {
|
||||
var inputs companyScopeInputs
|
||||
if err := json.Unmarshal([]byte(`[
|
||||
"example.com",
|
||||
{"kind":"icp","value":"京 ICP备 123号"},
|
||||
{"kind":"keyword","value":"Acme Security"}
|
||||
]`), &inputs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inputs) != 3 {
|
||||
t.Fatalf("input count=%d want 3", len(inputs))
|
||||
}
|
||||
if inputs[0].Kind != "" || inputs[0].Value != "example.com" {
|
||||
t.Fatalf("legacy rule changed: %+v", inputs[0])
|
||||
}
|
||||
if inputs[1].Kind != "icp" || inputs[2].Kind != "keyword" {
|
||||
t.Fatalf("structured rules changed: %+v", inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCompanyRejectsNormalizedDuplicateWithoutChangingScope(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
name := fmt.Sprintf("HTTP Strict Company %d", stamp)
|
||||
companyID, _, _, _, _, err := m.pg.Companies().CreateCompanyWithScope(name, "", []db.ScopeInput{
|
||||
{Kind: "domain", Value: fmt.Sprintf("existing-%d.example", stamp)},
|
||||
}, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = m.pg.Companies().DeleteCompany(companyID) }()
|
||||
|
||||
s := &Server{m: m}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"name": " " + name + " ",
|
||||
"scope": []map[string]string{{"kind": "domain", "value": fmt.Sprintf("new-%d.example", stamp)}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/companies", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
s.createCompany(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
scope, err := m.pg.Companies().GetScope(companyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(scope) != 1 || scope[0].Domain != fmt.Sprintf("existing-%d.example", stamp) {
|
||||
t.Fatalf("duplicate HTTP create changed existing scope: %+v", scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCompanyRefreshesLiveTaskCompanyIDs(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
companyID, _, err := m.pg.Companies().UpsertCompany(
|
||||
fmt.Sprintf("Delete Company DTO %d", time.Now().UnixNano()), "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := m.CreateTaskWithOptions("company deletion dto", "goal", db.TaskCreateOptions{
|
||||
CompanyIDs: []int64{companyID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
|
||||
|
||||
s := &Server{m: m}
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/companies/1", bytes.NewBufferString(`{}`))
|
||||
req.SetPathValue("id", i64s(companyID))
|
||||
rec := httptest.NewRecorder()
|
||||
s.deleteCompany(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete company status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if dto := taskDTO(task, "created"); len(dto.CompanyIDs) != 0 {
|
||||
t.Fatalf("live task DTO retained deleted company: %+v", dto.CompanyIDs)
|
||||
}
|
||||
persisted, err := m.pg.GetTask(mustTaskID(t, task.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted == nil || len(persisted.CompanyIDs) != 0 {
|
||||
t.Fatalf("persisted task retained deleted company: %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanyScopeHTTPErrorClassificationAndBounds(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := &Server{m: m}
|
||||
|
||||
t.Run("missing company is 404", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"scope":[]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/companies/999999999/scope", body)
|
||||
req.SetPathValue("id", "999999999")
|
||||
rec := httptest.NewRecorder()
|
||||
s.addCompanyScope(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized Unicode scope is 400", func(t *testing.T) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"name": "Oversized Scope",
|
||||
"scope": []map[string]string{{
|
||||
"kind": "keyword", "value": strings.Repeat("界", db.MaxCompanyScopeRawRunes+1),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/companies", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
s.createCompany(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too many scope rules is 400", func(t *testing.T) {
|
||||
scope := make([]map[string]string, db.MaxCompanyScopeRules+1)
|
||||
for i := range scope {
|
||||
scope[i] = map[string]string{"kind": "keyword", "value": fmt.Sprintf("keyword-%d", i)}
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"name": "Too Many Scope Rules", "scope": scope})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/companies", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
s.createCompany(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized request body is 413", func(t *testing.T) {
|
||||
body := `{"name":"` + strings.Repeat("x", maxCompanyMutationBodyBytes+1) + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/companies", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
s.createCompany(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListAssetsClassifiesValidationAndDatabaseErrors(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := &Server{m: m}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/assets?dsl=(", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.listAssets(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid DSL status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if err := m.pg.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/assets?type=root_domain", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
s.listAssets(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("database failure status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCompanyRejectsBadJSONAndReportsMissing(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := &Server{m: m}
|
||||
|
||||
companyID, _, err := m.pg.Companies().UpsertCompany(
|
||||
fmt.Sprintf("Delete JSON Company %d", time.Now().UnixNano()), "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = m.pg.Companies().DeleteCompany(companyID) }()
|
||||
|
||||
for _, body := range []string{`{"delete_assets":`, `{}` + `{}`} {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/companies/1", strings.NewReader(body))
|
||||
req.SetPathValue("id", i64s(companyID))
|
||||
rec := httptest.NewRecorder()
|
||||
s.deleteCompany(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("body=%q status=%d response=%s", body, rec.Code, rec.Body.String())
|
||||
}
|
||||
company, err := m.pg.Companies().GetCompany(companyID)
|
||||
if err != nil || company == nil {
|
||||
t.Fatalf("bad JSON deleted company: company=%v err=%v", company, err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/companies/999999999", nil)
|
||||
req.SetPathValue("id", "999999999")
|
||||
rec := httptest.NewRecorder()
|
||||
s.deleteCompany(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing delete status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanyScopeSystemFailureIsHTTP500(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := &Server{m: m}
|
||||
if err := m.pg.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/companies/1/scope", bytes.NewBufferString(`{"scope":[]}`))
|
||||
req.SetPathValue("id", "1")
|
||||
rec := httptest.NewRecorder()
|
||||
s.addCompanyScope(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxTaskAssetRequestBytes = 512 << 10
|
||||
|
||||
func writeTaskAssetError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrTaskAssetInvalid):
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, db.ErrTaskAssetTaskNotFound), errors.Is(err, db.ErrTaskAssetAssetNotFound):
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) attachTaskAssets(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskAssetRequestBytes)
|
||||
var request struct {
|
||||
AssetIDs []int64 `json:"asset_ids"`
|
||||
SourceSummary string `json:"source_summary"`
|
||||
Scope companyScopeInputs `json:"scope"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
request.SourceSummary = strings.TrimSpace(request.SourceSummary)
|
||||
taskID, _ := parseTaskID(task.ID)
|
||||
if request.Scope != nil && len(request.AssetIDs) > 0 {
|
||||
writeErr(w, http.StatusBadRequest, "scope 与 asset_ids 不能同时提交")
|
||||
return
|
||||
}
|
||||
if request.Scope != nil {
|
||||
mutation, err := s.m.Assets().RegisterTaskAssetScopes(taskID, request.Scope)
|
||||
if err != nil {
|
||||
writeTaskAssetError(w, err)
|
||||
return
|
||||
}
|
||||
task.Notify()
|
||||
writeJSON(w, http.StatusOK, mutation)
|
||||
return
|
||||
}
|
||||
mutation, err := s.m.Assets().AttachAssetsToTask(taskID, request.AssetIDs, request.SourceSummary)
|
||||
if err != nil {
|
||||
writeTaskAssetError(w, err)
|
||||
return
|
||||
}
|
||||
task.Notify()
|
||||
writeJSON(w, http.StatusOK, mutation)
|
||||
}
|
||||
|
||||
func (s *Server) detachTaskAsset(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
assetID, ok := pathInt(r, "assetID")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad asset id")
|
||||
return
|
||||
}
|
||||
taskID, _ := parseTaskID(task.ID)
|
||||
detached, err := s.m.Assets().DetachAssetFromTask(taskID, assetID)
|
||||
if err != nil {
|
||||
writeTaskAssetError(w, err)
|
||||
return
|
||||
}
|
||||
if !detached {
|
||||
writeErr(w, http.StatusNotFound, "asset is not associated with this task")
|
||||
return
|
||||
}
|
||||
task.Notify()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"detached": assetID})
|
||||
}
|
||||
|
||||
func (s *Server) taskIntentAssets(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
taskID, _ := parseTaskID(task.ID)
|
||||
assets, err := s.m.Assets().IntentAssets(taskID)
|
||||
if err != nil {
|
||||
writeTaskAssetError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"assets": assets})
|
||||
}
|
||||
Reference in new issue
Block a user