mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Task credentials (#3): declare test accounts at task creation, and never lose the accounts the agent registers on its own. - task_credentials table (unique per task+domain+username; upsert overwrites the password in place), idempotent schema migration - POST /api/tasks accepts credentials; GET/POST/DELETE /api/tasks/{id}/credentials serve the UI (manual add, e.g. relaying an OTP-verified account; delete) - empty password = the "agent generates" mode: the entry surfaces in the worker's graph overview with an explicit instruction to register that account with a strong password and record it - new tools save_task_credential / list_task_credentials for worker and pentest; the graph overview injects the task's credentials each round with a standing note: reuse matching entries, always record accounts you register - New Task dialog: optional credentials section (domain, username, password, note) with a dedicated-test-accounts hint - task detail: Credentials tab, passwords masked with explicit reveal/copy, agent-recorded badge, light polling so mid-run recordings appear One-shot binding migration (settings-flag-guarded, same pattern as the other seeds) grants the two tools to worker+pentest on databases seeded before this change; fresh DBs get them via WorkerTools()/the pentest catalog. Closes #3
113 lines
4.1 KiB
Go
113 lines
4.1 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
)
|
|
|
|
// Task credentials (#3): declared at creation, listed/upserted/deleted via the
|
|
// API, and recorded by the agent path (ExplorationStore upsert, source=agent).
|
|
func TestTaskCredentialsFlow(t *testing.T) {
|
|
s, _, request := trafficEvidenceServer(t)
|
|
|
|
// Create a task carrying two credentials: one complete, one "agent generates".
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
resp := request("POST", "/api/tasks", `{
|
|
"description": "credential flow test",
|
|
"goal": "verify task credentials",
|
|
"credentials": [
|
|
{"domain": "target.example.com", "username": "alias@test", "password": "s3cret!", "note": "program alias account"},
|
|
{"domain": "target.example.com", "username": "", "password": ""}
|
|
]
|
|
}`)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("an entry without a username must be rejected, got HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
resp = request("POST", "/api/tasks", `{
|
|
"description": "credential flow test",
|
|
"goal": "verify task credentials",
|
|
"credentials": [
|
|
{"domain": "target.example.com", "username": "alias@test", "password": "s3cret!", "note": "program alias account"},
|
|
{"domain": "app.target.example.com", "username": "agent-account", "password": ""}
|
|
]
|
|
}`)
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("create with credentials failed, HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &created); err != nil || created.ID == "" {
|
|
t.Fatalf("bad create response: %s", resp.Body.String())
|
|
}
|
|
taskNum := json.Number(created.ID)
|
|
id, err := taskNum.Int64()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { s.m.PG().Exec(`DELETE FROM tasks WHERE id=$1`, id) })
|
|
|
|
// List: both rows, user source, order stable.
|
|
resp = request("GET", fmt.Sprintf("/api/tasks/%s/credentials", created.ID), "")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("list failed, HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var list []db.TaskCredential
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Fatalf("want 2 credentials, got %d: %s", len(list), resp.Body.String())
|
|
}
|
|
if list[0].Domain != "target.example.com" || list[0].Username != "alias@test" || list[0].Password != "s3cret!" || list[0].Note != "program alias account" {
|
|
t.Fatalf("declared credential mismatch: %+v", list[0])
|
|
}
|
|
if list[1].Password != "" {
|
|
t.Fatalf("agent-generates entry must keep an empty password until recorded, got %q", list[1].Password)
|
|
}
|
|
|
|
// The agent path records the generated password onto the empty entry.
|
|
if _, err := s.m.PG().UpsertTaskCredential(id, db.TaskCredentialInput{
|
|
Domain: "app.target.example.com", Username: "agent-account", Password: "generated-strong-pass",
|
|
Source: "agent",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, err = s.m.PG().TaskCredentials(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list) != 2 || list[1].Password != "generated-strong-pass" || list[1].Source != "agent" {
|
|
t.Fatalf("agent record must update in place, got %+v", list[1])
|
|
}
|
|
|
|
// Manual add + delete through the API.
|
|
resp = request("POST", fmt.Sprintf("/api/tasks/%s/credentials", created.ID),
|
|
`{"domain":"other.example.com","username":"relay","password":"otp-verified"}`)
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("manual add failed, HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var added db.TaskCredential
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &added); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp = request("DELETE", fmt.Sprintf("/api/tasks/%s/credentials/%d", created.ID, added.ID), "")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("delete failed, HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
list, _ = s.m.PG().TaskCredentials(id)
|
|
if len(list) != 2 {
|
|
t.Fatalf("after delete want 2 credentials, got %d", len(list))
|
|
}
|
|
|
|
// A delete from another task must not cross tasks.
|
|
resp = request("DELETE", fmt.Sprintf("/api/tasks/%s/credentials/999999", created.ID), "")
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Fatalf("foreign credential delete must 404, got HTTP %d", resp.Code)
|
|
}
|
|
}
|