Merge pull request 'Implement NIP-98 HTTP auth for GET/PUT/DELETE /subscription/email' (#9) from mailship-uxf-implement-nip-98-auth-for-get-put-delete-9b9 into main
Reviewed-on: #9 Reviewed-by: matt <matt@lorentz.is>
This commit is contained in:
commit
d98d034989
4 changed files with 221 additions and 52 deletions
19
README.md
19
README.md
|
|
@ -49,27 +49,30 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
|
||||||
### PUT /subscription/email
|
### PUT /subscription/email
|
||||||
Idempotently register or update an email subscription. Re-sends the confirmation
|
Idempotently register or update an email subscription. Re-sends the confirmation
|
||||||
email only when the subscription is new or the email address changed; a frequency
|
email only when the subscription is new or the email address changed; a frequency
|
||||||
change keeps the existing confirmation.
|
change keeps the existing confirmation. The pubkey is extracted from the NIP-98
|
||||||
|
Authorization header — the body does not include a `pubkey` field.
|
||||||
|
|
||||||
```
|
```
|
||||||
Body: { email, frequency, pubkey }
|
Body: { email, frequency }
|
||||||
Auth: NIP-98 (planned)
|
Auth: NIP-98 (Nostr <base64> Authorization header)
|
||||||
Response: { key, callback }
|
Response: { key, callback }
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /subscription/email?pubkey=...
|
### GET /subscription/email
|
||||||
Look up an existing subscription, so clients can avoid re-registering (and
|
Look up an existing subscription for the authenticated pubkey, so clients can
|
||||||
re-confirming) when settings haven't changed. Returns 404 if none exists.
|
avoid re-registering (and re-confirming) when settings haven't changed.
|
||||||
|
Returns 404 if none exists.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
Auth: NIP-98 (Nostr <base64> Authorization header)
|
||||||
Response: { key, callback, email, frequency, confirmed }
|
Response: { key, callback, email, frequency, confirmed }
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /subscription/:key
|
### DELETE /subscription/:key
|
||||||
Unsubscribe.
|
Unsubscribe. Verifies the NIP-98 auth pubkey matches the subscription owner.
|
||||||
|
|
||||||
```
|
```
|
||||||
Auth: NIP-98 (planned)
|
Auth: NIP-98 (Nostr <base64> Authorization header)
|
||||||
Response: { ok: true }
|
Response: { ok: true }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
34
script/nip98-auth-header.mjs
Normal file
34
script/nip98-auth-header.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Generates a NIP-98 Authorization header value ("Nostr <base64>") for
|
||||||
|
// testing purposes.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node script/nip98-auth-header.mjs <secret-hex> <url> <method> [body]
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// export AUTH=$(node script/nip98-auth-header.mjs \
|
||||||
|
// "$SECRET" "$BASE_URL/subscription/email" PUT '{"email":"a@b.com","frequency":"daily"}')
|
||||||
|
// curl -H "Authorization: $AUTH" ...
|
||||||
|
|
||||||
|
import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util'
|
||||||
|
import { Nip01Signer } from '@welshman/signer'
|
||||||
|
|
||||||
|
const [, , secret, url, method, body] = process.argv
|
||||||
|
|
||||||
|
if (!secret || !url) {
|
||||||
|
console.error('Usage: node script/nip98-auth-header.mjs <secret-hex> <url> <method> [body]')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const signer = Nip01Signer.fromSecret(secret)
|
||||||
|
|
||||||
|
// Create the unsigned auth event template
|
||||||
|
const event = await makeHttpAuth(url, method || 'GET', body || undefined)
|
||||||
|
|
||||||
|
// Stamp (created_at, pubkey, id) and sign
|
||||||
|
const signed = await signer.sign(event)
|
||||||
|
|
||||||
|
// Encode as "Nostr <base64>"
|
||||||
|
const header = makeHttpAuthHeader(signed)
|
||||||
|
|
||||||
|
console.log(header)
|
||||||
118
src/server.ts
118
src/server.ts
|
|
@ -7,9 +7,79 @@ import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, Act
|
||||||
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
|
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
|
||||||
import { load } from '@welshman/net'
|
import { load } from '@welshman/net'
|
||||||
import { getIdFilters } from '@welshman/util'
|
import { getIdFilters } from '@welshman/util'
|
||||||
|
import crypto from 'crypto'
|
||||||
import { verifyEvent } from 'nostr-tools/pure'
|
import { verifyEvent } from 'nostr-tools/pure'
|
||||||
|
|
||||||
// Endpoints
|
// ── NIP-98 HTTP Auth ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Verify a NIP-98 Authorization header and return the authenticated pubkey,
|
||||||
|
// or null if the header is missing, malformed, or invalid.
|
||||||
|
//
|
||||||
|
// The client constructs the auth event via @welshman/util:
|
||||||
|
// makeHttpAuth(url, method, body) → event
|
||||||
|
// makeHttpAuthHeader(event) → "Nostr <base64>"
|
||||||
|
//
|
||||||
|
// We decode, verify kind=27235, verifyEvent, then check u / method / payload
|
||||||
|
// tags against the actual request URL / method / body.
|
||||||
|
const verifyNip98Auth = async (req: Request): Promise<string | null> => {
|
||||||
|
const authHeader = req.headers.authorization
|
||||||
|
if (!authHeader) return null
|
||||||
|
|
||||||
|
// Format: "Nostr <base64>"
|
||||||
|
const match = authHeader.match(/^Nostr\s+(.+)$/)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
// Decode base64
|
||||||
|
let eventJson: string
|
||||||
|
try {
|
||||||
|
eventJson = Buffer.from(match[1], 'base64').toString('utf-8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse event
|
||||||
|
let event: any
|
||||||
|
try {
|
||||||
|
event = JSON.parse(eventJson)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be kind 27235 (HTTP Auth)
|
||||||
|
if (event.kind !== 27235) return null
|
||||||
|
|
||||||
|
// Verify event signature and id hash
|
||||||
|
if (!verifyEvent(event)) return null
|
||||||
|
|
||||||
|
const tags = event.tags || []
|
||||||
|
|
||||||
|
// Find required tags
|
||||||
|
const uTag = tags.find((t: string[]) => t[0] === 'u')
|
||||||
|
const methodTag = tags.find((t: string[]) => t[0] === 'method')
|
||||||
|
const payloadTag = tags.find((t: string[]) => t[0] === 'payload')
|
||||||
|
|
||||||
|
// Build the full URL the server received
|
||||||
|
const expectedUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
|
||||||
|
|
||||||
|
// u tag must match the request URL exactly
|
||||||
|
if (!uTag || uTag[1] !== expectedUrl) return null
|
||||||
|
|
||||||
|
// method tag must match the HTTP method (upper case)
|
||||||
|
if (!methodTag || methodTag[1] !== req.method.toUpperCase()) return null
|
||||||
|
|
||||||
|
// For requests with a body, check payload tag is the SHA256 of the body
|
||||||
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase())) {
|
||||||
|
if (req.body && Object.keys(req.body).length > 0) {
|
||||||
|
const bodyStr = JSON.stringify(req.body)
|
||||||
|
const expectedPayload = crypto.createHash('sha256').update(bodyStr).digest('hex')
|
||||||
|
if (!payloadTag || payloadTag[1] !== expectedPayload) return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.pubkey as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Endpoints ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const server: express.Application = express()
|
export const server: express.Application = express()
|
||||||
|
|
||||||
|
|
@ -85,13 +155,15 @@ addRoute('get', '/', async (req: Request, res: Response) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Look up an existing email subscription for a pubkey, so clients can avoid
|
// Look up an existing email subscription for the authenticated pubkey, so
|
||||||
// re-registering (and re-confirming) when settings haven't changed.
|
// clients can avoid re-registering (and re-confirming) when settings
|
||||||
|
// haven't changed. Requires NIP-98 HTTP auth proving the caller controls
|
||||||
|
// the pubkey.
|
||||||
addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
|
addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
const { pubkey } = req.query
|
const pubkey = await verifyNip98Auth(req)
|
||||||
|
|
||||||
if (!pubkey || typeof pubkey !== 'string') {
|
if (!pubkey) {
|
||||||
return res.status(400).json({ error: 'pubkey is required' })
|
return res.status(401).json({ error: 'NIP-98 authorization required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = await getSubscriptionByPubkey(pubkey)
|
const sub = await getSubscriptionByPubkey(pubkey)
|
||||||
|
|
@ -111,9 +183,17 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Subscribe to email digests (idempotent PUT upsert)
|
// Subscribe to email digests (idempotent PUT upsert). Requires NIP-98 HTTP
|
||||||
|
// auth proving the caller controls the pubkey — the pubkey is extracted from
|
||||||
|
// the auth event, not from the request body.
|
||||||
addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
|
addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
const { email, frequency, pubkey } = req.body
|
const { email, frequency } = req.body
|
||||||
|
|
||||||
|
const pubkey = await verifyNip98Auth(req)
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return res.status(401).json({ error: 'NIP-98 authorization required' })
|
||||||
|
}
|
||||||
|
|
||||||
if (!email || !email.includes('@')) {
|
if (!email || !email.includes('@')) {
|
||||||
return res.status(400).json({ error: 'A valid email address is required' })
|
return res.status(400).json({ error: 'A valid email address is required' })
|
||||||
|
|
@ -123,15 +203,6 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
|
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pubkey) {
|
|
||||||
return res.status(400).json({ error: 'pubkey is required' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Verify NIP-98 auth header
|
|
||||||
// const auth = req.headers.authorization
|
|
||||||
// if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' })
|
|
||||||
// Verify using @welshman/util makeHttpAuth
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await registerSubscription({ pubkey, email, frequency })
|
const result = await registerSubscription({ pubkey, email, frequency })
|
||||||
res.json(result)
|
res.json(result)
|
||||||
|
|
@ -152,17 +223,26 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Delete subscription
|
// Delete subscription. Requires NIP-98 HTTP auth proving the caller controls
|
||||||
|
// the pubkey that owns this subscription.
|
||||||
addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
|
addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
|
||||||
const { key } = req.params
|
const { key } = req.params
|
||||||
|
|
||||||
|
const pubkey = await verifyNip98Auth(req)
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return res.status(401).json({ error: 'NIP-98 authorization required' })
|
||||||
|
}
|
||||||
|
|
||||||
const sub = await getSubscriptionByKey(key)
|
const sub = await getSubscriptionByKey(key)
|
||||||
|
|
||||||
if (!sub) {
|
if (!sub) {
|
||||||
return res.status(404).json({ error: 'Subscription not found' })
|
return res.status(404).json({ error: 'Subscription not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Verify NIP-98 auth header matches sub.pubkey
|
if (sub.pubkey !== pubkey) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: you do not own this subscription' })
|
||||||
|
}
|
||||||
|
|
||||||
await unsubscribeAction({ token: key })
|
await unsubscribeAction({ token: key })
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
|
|
|
||||||
102
test/integration.sh
Executable file → Normal file
102
test/integration.sh
Executable file → Normal file
|
|
@ -38,11 +38,31 @@ if [ ! -d "dist" ]; then
|
||||||
npx tsc
|
npx tsc
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate a random secret for the test
|
# Generate secrets for the test: one for the server, one for the client
|
||||||
SECRET="$(openssl rand -hex 32)"
|
SERVER_SECRET="$(openssl rand -hex 32)"
|
||||||
|
CLIENT_SECRET="$(openssl rand -hex 32)"
|
||||||
|
|
||||||
|
# Derive the client pubkey so we can look up subscriptions later
|
||||||
|
CLIENT_PUBKEY=$(node -e "
|
||||||
|
import {Nip01Signer} from '@welshman/signer';
|
||||||
|
const s = Nip01Signer.fromSecret('$CLIENT_SECRET');
|
||||||
|
s.getPubkey().then(p => console.log(p));
|
||||||
|
")
|
||||||
|
|
||||||
|
echo "Client pubkey: $CLIENT_PUBKEY"
|
||||||
|
|
||||||
|
# Helper to build a NIP-98 auth header
|
||||||
|
nip98_auth() {
|
||||||
|
local url="$1" method="$2" body="${3:-}"
|
||||||
|
if [ -n "$body" ]; then
|
||||||
|
node "$PROJECT_DIR/script/nip98-auth-header.mjs" "$CLIENT_SECRET" "$url" "$method" "$body"
|
||||||
|
else
|
||||||
|
node "$PROJECT_DIR/script/nip98-auth-header.mjs" "$CLIENT_SECRET" "$url" "$method"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Export env vars for the server
|
# Export env vars for the server
|
||||||
export MAILSHIP_SECRET="$SECRET"
|
export MAILSHIP_SECRET="$SERVER_SECRET"
|
||||||
export MAILSHIP_NAME="Mailship Test"
|
export MAILSHIP_NAME="Mailship Test"
|
||||||
export MAILSHIP_URL="$BASE_URL"
|
export MAILSHIP_URL="$BASE_URL"
|
||||||
export BASE_URL="$BASE_URL"
|
export BASE_URL="$BASE_URL"
|
||||||
|
|
@ -55,6 +75,7 @@ export DEFAULT_RELAYS="wss://relay.damus.io"
|
||||||
export INDEXER_RELAYS="wss://purplepag.es"
|
export INDEXER_RELAYS="wss://purplepag.es"
|
||||||
export SEARCH_RELAYS="wss://relay.nostr.band"
|
export SEARCH_RELAYS="wss://relay.nostr.band"
|
||||||
export PORT="$PORT"
|
export PORT="$PORT"
|
||||||
|
export CORS_ORIGIN="$BASE_URL"
|
||||||
export DATA_DIR="$PROJECT_DIR/test-data"
|
export DATA_DIR="$PROJECT_DIR/test-data"
|
||||||
|
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
@ -117,11 +138,13 @@ echo "1. Health check"
|
||||||
HEALTH=$(curl -s "$BASE_URL/")
|
HEALTH=$(curl -s "$BASE_URL/")
|
||||||
check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship"
|
check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship"
|
||||||
|
|
||||||
# Test 2: Register subscription
|
# Test 2: Register subscription with NIP-98 auth
|
||||||
echo ""
|
echo ""
|
||||||
echo "2. Register subscription"
|
echo "2. Register subscription (NIP-98 auth)"
|
||||||
|
AUTH_PUT=$(nip98_auth "$BASE_URL/subscription/email" PUT '{"email":"test@example.com","frequency":"daily"}')
|
||||||
REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \
|
REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \
|
||||||
-d '{"email":"test@example.com","frequency":"daily","pubkey":"abc123"}')
|
-H "Authorization: $AUTH_PUT" \
|
||||||
|
-d '{"email":"test@example.com","frequency":"daily"}')
|
||||||
|
|
||||||
KEY=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null)
|
KEY=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null)
|
||||||
CALLBACK=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('callback',''))" 2>/dev/null)
|
CALLBACK=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('callback',''))" 2>/dev/null)
|
||||||
|
|
@ -132,9 +155,31 @@ else
|
||||||
fail "Registration missing key or callback (got: $REG)"
|
fail "Registration missing key or callback (got: $REG)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 3: Confirm subscription
|
# Test 3: PUT without auth returns 401
|
||||||
echo ""
|
echo ""
|
||||||
echo "3. Confirm subscription"
|
echo "3. PUT without auth returns 401"
|
||||||
|
NO_AUTH=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"test@example.com","frequency":"daily"}')
|
||||||
|
check_field "No-auth PUT returns 401" "$NO_AUTH" "error" "NIP-98 authorization required"
|
||||||
|
|
||||||
|
# Test 4: GET /subscription/email with auth
|
||||||
|
echo ""
|
||||||
|
echo "4. GET subscription with auth"
|
||||||
|
AUTH_GET=$(nip98_auth "$BASE_URL/subscription/email" GET)
|
||||||
|
GET_RESP=$(curl -s "$BASE_URL/subscription/email" \
|
||||||
|
-H "Authorization: $AUTH_GET")
|
||||||
|
check_field "GET returns our email" "$GET_RESP" "email" "test@example.com"
|
||||||
|
check_field "GET returns frequency" "$GET_RESP" "frequency" "daily"
|
||||||
|
|
||||||
|
# Test 5: GET without auth returns 401
|
||||||
|
echo ""
|
||||||
|
echo "5. GET without auth returns 401"
|
||||||
|
GET_NO_AUTH=$(curl -s "$BASE_URL/subscription/email")
|
||||||
|
check_field "No-auth GET returns 401" "$GET_NO_AUTH" "error" "NIP-98 authorization required"
|
||||||
|
|
||||||
|
# Test 6: Confirm subscription
|
||||||
|
echo ""
|
||||||
|
echo "6. Confirm subscription"
|
||||||
CONFIRM=$(curl -s "$BASE_URL/confirm?token=$KEY" 2>&1)
|
CONFIRM=$(curl -s "$BASE_URL/confirm?token=$KEY" 2>&1)
|
||||||
if echo "$CONFIRM" | grep -qi "success"; then
|
if echo "$CONFIRM" | grep -qi "success"; then
|
||||||
pass "Confirmation page shows success"
|
pass "Confirmation page shows success"
|
||||||
|
|
@ -142,20 +187,20 @@ else
|
||||||
fail "Confirmation page doesn't show success"
|
fail "Confirmation page doesn't show success"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 4: Check SQLite state
|
# Test 7: Check SQLite state
|
||||||
echo ""
|
echo ""
|
||||||
echo "4. Database state"
|
echo "7. Database state"
|
||||||
CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE email='test@example.com';" 2>/dev/null)
|
CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null)
|
||||||
if [ -n "$CONFIRMED" ] && [ "$CONFIRMED" -gt 0 ]; then
|
if [ -n "$CONFIRMED" ] && [ "$CONFIRMED" -gt 0 ]; then
|
||||||
pass "Subscription confirmed in DB"
|
pass "Subscription confirmed in DB"
|
||||||
else
|
else
|
||||||
fail "Subscription not confirmed in DB"
|
fail "Subscription not confirmed in DB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 5: Push event to notify endpoint
|
# Test 8: Push event to notify endpoint
|
||||||
echo ""
|
echo ""
|
||||||
echo "5. Push event via notify"
|
echo "8. Push event via notify"
|
||||||
SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE email='test@example.com';" 2>/dev/null)
|
SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null)
|
||||||
|
|
||||||
# Fetch a real event from a relay
|
# Fetch a real event from a relay
|
||||||
EVENT_ID=$(nak req -k 1 -l 1 wss://relay.primal.net 2>/dev/null | head -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null)
|
EVENT_ID=$(nak req -k 1 -l 1 wss://relay.primal.net 2>/dev/null | head -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null)
|
||||||
|
|
@ -173,25 +218,25 @@ else
|
||||||
check_field "Event stored in DB" "$NOTIFY" "stored" "True"
|
check_field "Event stored in DB" "$NOTIFY" "stored" "True"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 6: Dedup
|
# Test 9: Dedup
|
||||||
echo ""
|
echo ""
|
||||||
echo "6. Dedup"
|
echo "9. Dedup"
|
||||||
if [ -n "$EVENT_ID" ]; then
|
if [ -n "$EVENT_ID" ]; then
|
||||||
DEDUP=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \
|
DEDUP=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \
|
||||||
-d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}")
|
-d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}")
|
||||||
check_field "Duplicate event rejected" "$DEDUP" "stored" "False"
|
check_field "Duplicate event rejected" "$DEDUP" "stored" "False"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 7: 404 for nonexistent subscription
|
# Test 10: 404 for nonexistent subscription
|
||||||
echo ""
|
echo ""
|
||||||
echo "7. 404 for nonexistent subscription"
|
echo "10. 404 for nonexistent subscription"
|
||||||
NOT_FOUND=$(curl -s "$BASE_URL/notify/nonexistent-id" -X POST -H "Content-Type: application/json" \
|
NOT_FOUND=$(curl -s "$BASE_URL/notify/nonexistent-id" -X POST -H "Content-Type: application/json" \
|
||||||
-d '{"id":"abc","relay":"wss://relay.primal.net"}')
|
-d '{"id":"abc","relay":"wss://relay.primal.net"}')
|
||||||
check_field "Nonexistent subscription returns 404" "$NOT_FOUND" "error" "Subscription not found"
|
check_field "Nonexistent subscription returns 404" "$NOT_FOUND" "error" "Subscription not found"
|
||||||
|
|
||||||
# Test 8: Unsubscribe
|
# Test 11: Unsubscribe
|
||||||
echo ""
|
echo ""
|
||||||
echo "8. Unsubscribe"
|
echo "11. Unsubscribe"
|
||||||
UNSUB=$(curl -s "$BASE_URL/unsubscribe?token=$KEY")
|
UNSUB=$(curl -s "$BASE_URL/unsubscribe?token=$KEY")
|
||||||
if echo "$UNSUB" | grep -qi "unsubscribed\|success"; then
|
if echo "$UNSUB" | grep -qi "unsubscribed\|success"; then
|
||||||
pass "Unsubscribe page renders"
|
pass "Unsubscribe page renders"
|
||||||
|
|
@ -199,21 +244,28 @@ else
|
||||||
fail "Unsubscribe page didn't render"
|
fail "Unsubscribe page didn't render"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 9: Notify after unsubscribe returns 404
|
# Test 12: Notify after unsubscribe returns 404
|
||||||
echo ""
|
echo ""
|
||||||
echo "9. No push after unsubscribe"
|
echo "12. No push after unsubscribe"
|
||||||
if [ -n "$EVENT_ID" ]; then
|
if [ -n "$EVENT_ID" ]; then
|
||||||
AFTER_UNSUB=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \
|
AFTER_UNSUB=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \
|
||||||
-d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}")
|
-d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}")
|
||||||
check_field "Push after unsubscribe returns 404" "$AFTER_UNSUB" "error" "Subscription not active"
|
check_field "Push after unsubscribe returns 404" "$AFTER_UNSUB" "error" "Subscription not active"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 10: Delete subscription
|
# Test 13: Delete subscription with auth
|
||||||
echo ""
|
echo ""
|
||||||
echo "10. Delete subscription"
|
echo "13. Delete subscription with NIP-98 auth"
|
||||||
DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE 2>&1)
|
AUTH_DEL=$(nip98_auth "$BASE_URL/subscription/$KEY" DELETE)
|
||||||
|
DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE -H "Authorization: $AUTH_DEL")
|
||||||
check_field "Delete returns ok" "$DELETE" "ok" "True"
|
check_field "Delete returns ok" "$DELETE" "ok" "True"
|
||||||
|
|
||||||
|
# Test 14: Delete without auth returns 401
|
||||||
|
echo ""
|
||||||
|
echo "14. Delete without auth returns 401"
|
||||||
|
DEL_NO_AUTH=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE)
|
||||||
|
check_field "No-auth DELETE returns 401" "$DEL_NO_AUTH" "error" "NIP-98 authorization required"
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue