# Agent API Test Plan

**Scope:** Verify all security features from #697 (Folder Permissions) and #698 (API Access Control) from an API agent's perspective.

**Method:** Direct HTTP calls against the NexusCRM backend API. No UI involved.

**Prerequisites:**
- NexusCRM backend running with `SKIP_ASSERTIONS=true`
- TiDB Cloud branch `ian-security-test` (userPrefix: `4V471DP11YTh49h`)
- Test Connected Apps created (see Setup section)

---

## Setup: Test Fixtures

### Agent Users

| ID | Email | Profile | Purpose |
|---|---|---|---|
| `AGENT_READONLY_001` | readonly-agent@nexus.agent | standard_user | Read-only agent |
| `AGENT_READWRITE_001` | readwrite-agent@nexus.agent | standard_user | Full CRUD agent |

### Connected Apps

| ID | ConsumerKey | Scopes | RunAsUserId | Purpose |
|---|---|---|---|---|
| `app-readonly` | `reporting-agent-key` | `api.read` | AGENT_READONLY_001 | Read-only reporting agent |
| `app-readwrite` | `dataentry-agent-key` | `api` | AGENT_READWRITE_001 | Full CRUD data entry agent |
| `app-openid` | `openid-only-key` | `openid` | AGENT_READONLY_001 | Identity-only app |
| `test-gosf-client` | `test-gosf-client` | NULL (legacy) | NULL (first admin) | Legacy full-access app |

---

## A. Authentication & Token Lifecycle

### A1. client_credentials — happy path
```
POST /services/oauth2/token
  grant_type=client_credentials
  client_id=reporting-agent-key
  client_secret=reporting-agent-secret

Expected: 200, access_token present, scope="api.read" in response
```
- [ ] Tested
- [ ] Passed

### A2. client_credentials — RunAsUserId respected
```
# Token from app-readonly should run as AGENT_READONLY_001
POST /services/oauth2/introspect
  token=<token from A1>

Expected: sub=AGENT_READONLY_001, username=readonly-agent@nexus.agent
```
- [ ] Tested
- [ ] Passed

### A3. client_credentials — no RunAsUserId (legacy deprecation warning)
```
POST /services/oauth2/token
  grant_type=client_credentials
  client_id=test-gosf-client
  client_secret=test-gosf-secret-123

Expected: 200, X-Deprecation-Warning header present
```
- [ ] Tested
- [ ] Passed

### A4. client_credentials — invalid secret
```
POST /services/oauth2/token
  grant_type=client_credentials
  client_id=reporting-agent-key
  client_secret=wrong-secret

Expected: 401 Unauthorized
```
- [ ] Tested
- [ ] Passed

### A5. client_credentials — deactivated app
```
# After setting app IsActive=0 in DB
POST /services/oauth2/token
  grant_type=client_credentials
  client_id=reporting-agent-key
  client_secret=reporting-agent-secret

Expected: 403 "connected app is disabled"
```
- [ ] Tested
- [ ] Passed

### A6. password grant — requires client_id
```
POST /services/oauth2/token
  grant_type=password
  username=admin@nexuscrm.io
  password=xxx

Expected: 400 {"error":"invalid_request","error_description":"client_id is required for password grant"}
```
- [ ] Tested
- [ ] Passed

### A7. Token introspection — valid token
```
POST /services/oauth2/introspect
  token=<valid token>

Expected: 200, active=true, sub, username, client_id, exp, iat
```
- [ ] Tested
- [ ] Passed

### A8. Token introspection — invalid/expired token
```
POST /services/oauth2/introspect
  token=invalid-garbage-token

Expected: 200, {"active":false}
```
- [ ] Tested
- [ ] Passed

### A9. Token revocation — revoked token rejected
```
POST /services/oauth2/revoke
  token=<valid token>
  
# Then try using the revoked token:
GET /services/data/v63.0/folders
  Authorization: Bearer <revoked token>

Expected: revoke returns 200, subsequent API call returns 401
```
- [ ] Tested
- [ ] Passed

---

## B. Scope Enforcement

### B1. scope=api → full API access
```
# Token from app-readwrite (scope=api)
GET  /services/data/v63.0/folders         → 200
POST /services/data/v63.0/folders         → 200 (create)
GET  /services/data/v63.0/query?q=...     → 200
```
- [ ] Tested
- [ ] Passed

### B2. scope=api.read → GET allowed, POST/PATCH/DELETE blocked
```
# Token from app-readonly (scope=api.read)
GET    /services/data/v63.0/folders              → 200
GET    /services/data/v63.0/query?q=SELECT...    → 200
POST   /services/data/v63.0/folders              → 403 INSUFFICIENT_SCOPE
PATCH  /services/data/v63.0/folders/:id          → 403 INSUFFICIENT_SCOPE
DELETE /services/data/v63.0/folders/:id          → 403 INSUFFICIENT_SCOPE
POST   /services/data/v63.0/sobjects/Account     → 403 INSUFFICIENT_SCOPE
```
- [ ] Tested
- [ ] Passed

### B3. scope=openid → only /userinfo works
```
# Token from app-openid (scope=openid)
GET /services/oauth2/userinfo                    → 200
GET /services/data/v63.0/folders                 → 403 INSUFFICIENT_SCOPE
GET /services/data/v63.0/sobjects/Account        → 403 INSUFFICIENT_SCOPE
GET /services/data/v63.0/query?q=SELECT...       → 403 INSUFFICIENT_SCOPE
```
- [ ] Tested
- [ ] Passed

### B4. scope=NULL (legacy) → full access (backward compat)
```
# Token from test-gosf-client (scope=NULL)
GET    /services/data/v63.0/folders    → 200
POST   /services/data/v63.0/folders    → 200
DELETE /services/data/v63.0/folders/:id → 204
```
- [ ] Tested
- [ ] Passed

### B5. Scope intersection — client requests scope not on app
```
# app-readonly has scope=api.read
# Client requests scope=api at token time (if password grant with client_id)
# Intersection of {api.read} ∩ {api} = empty → rejection

Expected: error "Requested scope is not permitted by this Connected App"
```
- [ ] Tested
- [ ] Passed

---

## C. Folder Permission — Access Control

### C1. Admin (SuperUser) sees all folders
```
# Token from legacy admin app (test-gosf-client)
GET /services/data/v63.0/folders

Expected: all folders visible (Hidden + Public + Shared)
```
- [ ] Tested
- [ ] Passed

### C2. Non-admin agent sees only accessible folders
```
# Token from app-readwrite (runs as AGENT_READWRITE_001, standard_user)
GET /services/data/v63.0/folders

Expected: only folders with:
  - CreatedById/OwnerId = AGENT_READWRITE_001 (personal)
  - FolderShare matching user/role/group/organization
  - Public folders with Organization View share
```
- [ ] Tested
- [ ] Passed

### C3. Personal folder lazy creation
```
# First call from a new agent user
GET /services/data/v63.0/folders

Expected: auto-creates 2 personal folders:
  - "My Personal Custom Reports" (type=Report, accessType=Hidden)
  - "My Personal Dashboards" (type=Dashboard, accessType=Hidden)
```
- [ ] Tested
- [ ] Passed

### C4. Personal folder isolation — agent A can't see agent B's personal folders
```
# Token as AGENT_READONLY_001
GET /services/data/v63.0/folders

# Token as AGENT_READWRITE_001
GET /services/data/v63.0/folders

Expected: each agent's personal folders are only in their own list
```
- [ ] Tested
- [ ] Passed

### C5. FolderShare View — can read, can't write
```
# Create a folder as admin, share with AGENT_READONLY_001 at View level
# Then as AGENT_READONLY_001:
GET    /services/data/v63.0/folders/:id           → 200
GET    /services/data/v63.0/folders/:id/shares     → 200
PATCH  /services/data/v63.0/folders/:id           → 404 (access denied)
DELETE /services/data/v63.0/folders/:id           → 404 (access denied)
```
- [ ] Tested
- [ ] Passed

### C6. FolderShare Manage — can read + write + manage shares
```
# Share with AGENT_READWRITE_001 at Manage level
# Then as AGENT_READWRITE_001:
GET    /services/data/v63.0/folders/:id            → 200
PATCH  /services/data/v63.0/folders/:id            → 200
POST   /services/data/v63.0/folders/:id/shares     → 200 (can add shares)
DELETE /services/data/v63.0/folders/:id/shares/:sid → 204 (can remove shares)
```
- [ ] Tested
- [ ] Passed

### C7. No access → 404 (not 403, avoids folder ID enumeration)
```
# As AGENT_READONLY_001, try a folder they have NO access to
GET /services/data/v63.0/folders/<admin-private-folder-id>

Expected: 404 NOT_FOUND (not 403)
```
- [ ] Tested
- [ ] Passed

### C8. SOQL path — folder visibility filtering
```
# As non-admin agent:
GET /services/data/v63.0/query?q=SELECT Id,Name FROM ReportFolder

Expected: only returns folders the agent has access to (same set as GET /folders)
```
- [ ] Tested
- [ ] Passed

### C9. SOQL path — RunReports required
```
# If agent's profile doesn't have RunReports permission:
GET /services/data/v63.0/query?q=SELECT Id FROM ReportFolder

Expected: 0 results (VisibleFolderIDs returns empty when RunReports missing)
```
- [ ] Tested
- [ ] Passed

### C10. Additive grants — max access wins
```
# Agent has:
#   - View via direct User share
#   - Manage via Organization share
# Effective access should be Manage (max of View, Manage)
PATCH /services/data/v63.0/folders/:id → 200 (Manage wins)
```
- [ ] Tested
- [ ] Passed

---

## D. Folder CRUD Operations

### D1. Create folder
```
POST /services/data/v63.0/folders
  {"name":"Agent Report Folder","type":"Report","accessType":"Shared"}

Expected: 200, id returned
```
- [ ] Tested
- [ ] Passed

### D2. Update folder
```
PATCH /services/data/v63.0/folders/:id
  {"description":"Updated by agent"}

Expected: 200
```
- [ ] Tested
- [ ] Passed

### D3. Delete folder
```
DELETE /services/data/v63.0/folders/:id

Expected: 204
```
- [ ] Tested
- [ ] Passed

### D4. Add folder shares
```
POST /services/data/v63.0/folders/:id/shares
  {"shares":[{"sharedToId":"<userId>","sharedToType":"User","accessLevel":"View"}]}

Expected: 200, share created
```
- [ ] Tested
- [ ] Passed

### D5. Add Organization share
```
POST /services/data/v63.0/folders/:id/shares
  {"shares":[{"sharedToId":"ALL_INTERNAL","sharedToType":"Organization","accessLevel":"View"}]}

Expected: 200, share created (sentinel ID accepted)
```
- [ ] Tested
- [ ] Passed

### D6. Delete folder share
```
DELETE /services/data/v63.0/folders/:id/shares/:shareId

Expected: 204
```
- [ ] Tested
- [ ] Passed

### D7. Ownership transfer
```
POST /services/data/v63.0/folders/transfer-ownership
  {"sourceUserId":"<A>","targetUserId":"<B>"}

Expected: 200, OwnerId changed, CreatedById preserved
```
- [ ] Tested
- [ ] Passed

---

## E. Data API Operations (as Agent)

### E1. List sobjects
```
GET /services/data/v63.0/sobjects

Expected: 200, list of available objects
```
- [ ] Tested
- [ ] Passed

### E2. Describe object
```
GET /services/data/v63.0/sobjects/Account/describe

Expected: 200, object metadata
```
- [ ] Tested
- [ ] Passed

### E3. Query records (SOQL)
```
GET /services/data/v63.0/query?q=SELECT Id,Name FROM Account LIMIT 5

Expected: 200, records returned (subject to RLS)
```
- [ ] Tested
- [ ] Passed

### E4. Get record by ID
```
GET /services/data/v63.0/sobjects/Account/<id>

Expected: 200, record data
```
- [ ] Tested
- [ ] Passed

### E5. Create record (scope=api only)
```
POST /services/data/v63.0/sobjects/Account
  {"Name":"Agent Created Account"}

Expected: 201, id returned
```
- [ ] Tested
- [ ] Passed

### E6. Update record (scope=api only)
```
PATCH /services/data/v63.0/sobjects/Account/<id>
  {"Description":"Updated by agent"}

Expected: 204
```
- [ ] Tested
- [ ] Passed

### E7. Create record blocked by api.read scope
```
# Token with scope=api.read
POST /services/data/v63.0/sobjects/Account
  {"Name":"Should Fail"}

Expected: 403 INSUFFICIENT_SCOPE
```
- [ ] Tested
- [ ] Passed

---

## F. Rate Limiting

### F1. Normal request volume — passes
```
# 10 requests in rapid succession
for i in 1..10: GET /services/data/v63.0/folders

Expected: all 200
```
- [ ] Tested
- [ ] Passed

### F2. Excessive requests — rate limited
```
# > 1000 requests in 1 hour window (or adjust for test)
# Note: Current default is 1000/hr, 10000/day

Expected: 429 REQUEST_LIMIT_EXCEEDED after hitting limit
```
- [ ] Tested
- [ ] Passed (or documented as requires extended test)

---

## G. Connected App Management

### G1. Deactivate app → new tokens rejected
```
# Set IsActive=0 on app-readonly
# Then try client_credentials

Expected: 403 "connected app is disabled"
```
- [ ] Tested
- [ ] Passed

### G2. Reactivate app → tokens work again
```
# Set IsActive=1 on app-readonly
# Then try client_credentials

Expected: 200, token issued
```
- [ ] Tested
- [ ] Passed

### G3. Revoke all sessions for an app
```
# Update _System_Session SET IsRevoked=1 WHERE ConnectedAppId='app-readonly'
# Then try using existing token

Expected: 401 (session revoked)
```
- [ ] Tested
- [ ] Passed

---

## H. Multi-Agent Isolation

### H1. Agent A's personal folders invisible to Agent B
```
# As AGENT_READONLY_001: GET /folders → see own personal folders
# As AGENT_READWRITE_001: GET /folders → see own personal folders, NOT agent A's

Expected: personal folder lists are disjoint
```
- [ ] Tested
- [ ] Passed

### H2. Agent A's created folder invisible to Agent B (no share)
```
# As AGENT_READWRITE_001: create a folder
# As AGENT_READONLY_001: GET /folders → the new folder NOT in list

Expected: folder only visible to creator until explicitly shared
```
- [ ] Tested
- [ ] Passed

### H3. Shared folder visible to both agents
```
# As admin: create folder, add Organization View share
# As AGENT_READONLY_001: GET /folders → folder visible
# As AGENT_READWRITE_001: GET /folders → folder visible

Expected: both agents see the folder
```
- [ ] Tested
- [ ] Passed

---

## Previously Verified (from earlier manual testing)

| Test | Result | When |
|---|---|---|
| A3 (deprecation warning) | ✅ | Session 1 |
| A6 (password requires client_id) | ✅ | Session 1 |
| A7 (introspection valid) | ✅ | Session 1 |
| A8 (introspection invalid) | ✅ | Session 1 |
| B2 (api.read GET/POST) | ✅ | Session 1 |
| B3 (openid blocked from data API) | ✅ | Session 1 |
| C1 (admin sees all) | ✅ | Session 2 |
| C3 (lazy creation) | ✅ | Session 1 |
| D1 (create folder) | ✅ | Session 2 |
| D3 (delete folder) | ✅ | Session 2 |
| D4 (add user share) | ✅ | Session 2 |
| D5 (organization share) | ✅ | Session 2 |
| D7 (ownership transfer) | ✅ | Session 2 |
