feat: seed script, forgot-password, hover cards, shortcuts, OpenAPI docs, runbook (phase 12)
- scripts/seed.go: idempotent demo data per §11.11 (make seed) - forgot/reset password: SMTP-gated, one-shot TTL tokens, uniform responses against enumeration, sessions revoked on reset; login page link + pages - profile hover cards on [data-user-card] elements (§11.13) - keyboard shortcuts: g b/m/t/h navigation, / focuses search (§10) - bulk archive endpoint (§11.9) - hand-written OpenAPI 3.1 covering §6, served at /api/docs + yaml download - make backup / make restore (mongodump archive via the mongo container) - README: quick start, demo data, runbook, breaker/job operations, working Caddy + nginx reverse-proxy samples (WS block, client_max_body_size), documented later-stubs (§11.24) - smoke.sh now exercises register → logout → login → me → board → pages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+187
@@ -0,0 +1,187 @@
|
||||
// Command seed populates a demo environment (§11.11): one demo customer
|
||||
// (offline ticketing type), 1 admin, 2 consultants, 6 developers, pool
|
||||
// memberships, and sample conversations. Idempotent: existing users (by
|
||||
// email) and customers (by name) are reused, not duplicated.
|
||||
//
|
||||
// MONGO_SEED_URI=mongodb://127.0.0.1:27017 go run ./scripts
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"bountyboard/internal/auth"
|
||||
"bountyboard/internal/config"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/ulid"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
const demoPassword = "demo-pass-123"
|
||||
|
||||
func main() {
|
||||
_ = config.LoadDotEnv(".env")
|
||||
uri := os.Getenv("MONGO_SEED_URI")
|
||||
if uri == "" {
|
||||
uri = "mongodb://127.0.0.1:27017"
|
||||
}
|
||||
dbName := os.Getenv("MONGO_DB")
|
||||
if dbName == "" {
|
||||
dbName = "bountyboard"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := mongo.Connect(options.Client().ApplyURI(uri).
|
||||
SetServerSelectionTimeout(5 * time.Second))
|
||||
if err != nil {
|
||||
log.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer client.Disconnect(context.Background())
|
||||
st := &store.Store{Client: client, DB: client.Database(dbName)}
|
||||
if err := store.EnsureIndexes(ctx, st.DB); err != nil {
|
||||
log.Fatalf("indexes: %v", err)
|
||||
}
|
||||
|
||||
user := func(email, name string, roles domain.Roles) *domain.User {
|
||||
if u, err := st.UserByEmail(ctx, email); err == nil {
|
||||
fmt.Printf(" exists: %s\n", email)
|
||||
return u
|
||||
}
|
||||
hash, err := auth.HashPassword(demoPassword)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
u := &domain.User{
|
||||
ID: ulid.New(), Email: email, Name: name, Roles: roles,
|
||||
Auth: domain.Auth{Local: &domain.LocalAuth{PasswordHash: hash}},
|
||||
Settings: domain.UserSettings{Theme: "light",
|
||||
Notifications: domain.NotificationPrefs{Email: true, InApp: true}},
|
||||
Bio: "Seeded demo account.",
|
||||
}
|
||||
if err := st.CreateUser(ctx, u); err != nil {
|
||||
log.Fatalf("create %s: %v", email, err)
|
||||
}
|
||||
fmt.Printf(" created: %s (password %q)\n", email, demoPassword)
|
||||
return u
|
||||
}
|
||||
|
||||
fmt.Println("Seeding users…")
|
||||
user("seed-admin@example.com", "Seed Admin", domain.Roles{Admin: true})
|
||||
clara := user("clara@example.com", "Clara Consultant", domain.Roles{Consultant: true})
|
||||
carlos := user("carlos@example.com", "Carlos Consultant", domain.Roles{Consultant: true})
|
||||
devs := []*domain.User{}
|
||||
devNames := []string{"Dana", "Devon", "Dimitri", "Daria", "Dylan", "Drew"}
|
||||
for i, n := range devNames {
|
||||
devs = append(devs, user(fmt.Sprintf("dev%d@example.com", i+1),
|
||||
n+" Developer", domain.Roles{Developer: true}))
|
||||
}
|
||||
|
||||
fmt.Println("Seeding pools…")
|
||||
addPool := func(c, d *domain.User) {
|
||||
if err := st.AddToPool(ctx, c.ID, d.ID); err != nil && err != store.ErrDuplicate {
|
||||
log.Fatalf("pool: %v", err)
|
||||
}
|
||||
}
|
||||
for _, d := range devs[:4] {
|
||||
addPool(clara, d)
|
||||
}
|
||||
for _, d := range devs[2:] {
|
||||
addPool(carlos, d)
|
||||
}
|
||||
|
||||
fmt.Println("Seeding demo customer…")
|
||||
customers, err := st.ListCustomers(ctx, "", true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var demoCustomer *domain.Customer
|
||||
for i := range customers {
|
||||
if customers[i].Name == "ACME Demo" {
|
||||
demoCustomer = &customers[i]
|
||||
}
|
||||
}
|
||||
if demoCustomer == nil {
|
||||
demoCustomer = &domain.Customer{
|
||||
Name: "ACME Demo",
|
||||
Ticketing: domain.Ticketing{
|
||||
Type: domain.TicketingDemo, ProjectKey: "ACME", PollIntervalSec: 30,
|
||||
},
|
||||
ConsultantIDs: []string{clara.ID, carlos.ID},
|
||||
DefaultBudget: 1500,
|
||||
}
|
||||
if err := st.CreateCustomer(ctx, demoCustomer); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(" created: ACME Demo (demo ticketing — tickets appear within one poll)")
|
||||
} else {
|
||||
fmt.Println(" exists: ACME Demo")
|
||||
}
|
||||
|
||||
fmt.Println("Seeding conversations…")
|
||||
dm, err := st.FindOrCreateDM(ctx, clara.ID, devs[0].ID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
n, err := st.DB.Collection("messages").CountDocuments(ctx, bson.M{"conversationId": dm.ID})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if n == 0 {
|
||||
msgs := []struct {
|
||||
from *domain.User
|
||||
body string
|
||||
}{
|
||||
{clara, "<p>Hi Dana — I just added you to my pool. The ACME board fills up shortly.</p>"},
|
||||
{devs[0], "<p>Great, I will grab the <b>CSV importer</b> once it is published.</p>"},
|
||||
{clara, "<p>Perfect. Ping me here if any acceptance criteria are unclear.</p>"},
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if err := st.InsertMessage(ctx, &store.Message{
|
||||
ConversationID: dm.ID, SenderID: m.from.ID, Body: m.body,
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
fmt.Println(" created: DM Clara ↔ Dana with 3 messages")
|
||||
}
|
||||
groups, _ := st.ListConversations(ctx, clara.ID)
|
||||
hasGroup := false
|
||||
for _, g := range groups {
|
||||
if g.Kind == "group" && g.Title == "ACME standup" {
|
||||
hasGroup = true
|
||||
}
|
||||
}
|
||||
if !hasGroup {
|
||||
grp := &store.Conversation{
|
||||
Kind: "group", Title: "ACME standup",
|
||||
ParticipantIDs: []string{clara.ID, carlos.ID, devs[0].ID, devs[1].ID, devs[2].ID},
|
||||
}
|
||||
if err := st.CreateConversation(ctx, grp); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := st.InsertMessage(ctx, &store.Message{
|
||||
ConversationID: grp.ID, SenderID: carlos.ID,
|
||||
Body: "<p>Welcome to the ACME standup channel. Daily updates here, please.</p>",
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(" created: group 'ACME standup'")
|
||||
}
|
||||
|
||||
fmt.Println("\nSeed complete.")
|
||||
fmt.Printf("Demo accounts (password %q):\n", demoPassword)
|
||||
fmt.Println(" seed-admin@example.com (admin)")
|
||||
fmt.Println(" clara@example.com, carlos@example.com (consultants)")
|
||||
for i := range devNames {
|
||||
fmt.Printf(" dev%d@example.com (developer)\n", i+1)
|
||||
}
|
||||
}
|
||||
+39
-6
@@ -1,28 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Curl-based smoke test against a running stack (docker compose up -d).
|
||||
# Grows with the system; later phases add register→login→board coverage.
|
||||
# Curl-based smoke test (§13): register → login → healthz → board against a
|
||||
# running stack (docker compose up -d).
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8787}"
|
||||
JAR=$(mktemp)
|
||||
trap 'rm -f "$JAR" /tmp/smoke-*.json' EXIT
|
||||
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
|
||||
|
||||
echo "smoke: $BASE_URL"
|
||||
|
||||
# healthz must always be 200 while the process is up
|
||||
# 1. health endpoints
|
||||
code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \
|
||||
|| fail "healthz unreachable"
|
||||
[ "$code" = "200" ] || fail "healthz returned $code"
|
||||
grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected: $(cat /tmp/smoke-healthz.json)"
|
||||
grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected"
|
||||
echo " healthz ok"
|
||||
|
||||
# readyz reflects dependency state; required deps must be up for the smoke run
|
||||
code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \
|
||||
|| fail "readyz unreachable"
|
||||
[ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)"
|
||||
echo " readyz ok"
|
||||
|
||||
# metricsz exposes counters
|
||||
curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected"
|
||||
echo " metricsz ok"
|
||||
|
||||
# 2. register a throwaway developer
|
||||
EMAIL="smoke-$(date +%s)-$RANDOM@example.com"
|
||||
code=$(curl -sS -c "$JAR" -o /tmp/smoke-reg.json -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL\",\"name\":\"Smoke Test\",\"password\":\"smoke-pass-123\"}" \
|
||||
"$BASE_URL/api/v1/auth/register")
|
||||
[ "$code" = "201" ] || fail "register returned $code: $(cat /tmp/smoke-reg.json)"
|
||||
echo " register ok ($EMAIL)"
|
||||
|
||||
# 3. logout, then login again
|
||||
CSRF=$(awk '$6=="bb_csrf" {print $7}' "$JAR")
|
||||
curl -fsS -b "$JAR" -X POST -H "X-CSRF-Token: $CSRF" \
|
||||
"$BASE_URL/api/v1/auth/logout" -o /dev/null || fail "logout failed"
|
||||
code=$(curl -sS -c "$JAR" -o /tmp/smoke-login.json -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"smoke-pass-123\"}" \
|
||||
"$BASE_URL/api/v1/auth/login")
|
||||
[ "$code" = "200" ] || fail "login returned $code"
|
||||
echo " login ok"
|
||||
|
||||
# 4. authenticated me + developer board
|
||||
curl -fsS -b "$JAR" "$BASE_URL/api/v1/auth/me" | grep -q "$EMAIL" || fail "me missing email"
|
||||
echo " me ok"
|
||||
code=$(curl -sS -b "$JAR" -o /tmp/smoke-board.json -w '%{http_code}' "$BASE_URL/api/v1/board")
|
||||
[ "$code" = "200" ] || fail "board returned $code: $(cat /tmp/smoke-board.json)"
|
||||
grep -q '"tasks"' /tmp/smoke-board.json || fail "board body unexpected"
|
||||
echo " board ok"
|
||||
|
||||
# 5. pages render
|
||||
curl -fsS "$BASE_URL/login" | grep -q 'Log in · Bounty Board' || fail "login page broken"
|
||||
curl -fsS "$BASE_URL/api/docs" | grep -q 'Bounty Board API' || fail "api docs broken"
|
||||
echo " pages ok"
|
||||
|
||||
echo "SMOKE PASS"
|
||||
|
||||
Reference in New Issue
Block a user