POS_LOGIN.md is for whoever builds and tests the till: the flow in the order it
has to happen, the three endpoints with real request and response shapes, every
error code with its verbatim message, the multi-outlet picker rules, and how
staff and PINs are meant to be handled.
Three things in it are the ones people will otherwise get wrong. `store_id`
comes out of the login response and is never typed by anyone — that is the whole
change. `staff` is usually empty, including at the outlet this build ships
pointed at, so an empty list has to be a no-op and not a wipe. And enforcement
is currently off, which means an untokened request still works today but a token
that *is* sent is still fully checked.
scratch/liveloginproof signs in against the live endpoint with a password read
out of the database — never printed, never passed on a command line where it
would land in a shell history — and then checks the token opens what it should
and refuses what it should not. The token is truncated in its output for the
same reason: it is a bearer credential for a whole trading day.
Run against v1.3.98 in production:
POST /login 200 token minted, store_id 1135 resolved
GET /session 200
GET /staff 200
GET /catalogue?store_id=1135 200
GET /catalogue?store_id=1185 403 this session cannot reach outlet 1185
POST /health 202
The 403 is the one worth keeping: a valid token, refused at another tenant's
outlet. That is the hole this work existed to close, shut on live traffic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
4.0 KiB
Go
131 lines
4.0 KiB
Go
// Proves sign-in end to end against the deployed API.
|
|
//
|
|
// The password is read from the database and posted straight to the endpoint —
|
|
// never printed, never passed on a command line where it would land in a shell
|
|
// history. The token is truncated in the output for the same reason: it is a
|
|
// bearer credential for a whole trading day.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/joho/godotenv"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
const base = "https://fiesta.nearle.app/live/api/v1/pos"
|
|
|
|
func main() {
|
|
who := "rsselvapuram@gmail.com"
|
|
if len(os.Args) > 1 {
|
|
who = os.Args[1]
|
|
}
|
|
|
|
_ = godotenv.Load()
|
|
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
|
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
|
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
var pw string
|
|
db.Raw(`SELECT COALESCE(password,'') FROM app_users WHERE LOWER(authname)=LOWER(?) LIMIT 1`, who).Scan(&pw)
|
|
if pw == "" {
|
|
log.Fatalf("%s has no password set", who)
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"authname": who, "password": pw, "terminal_id": "PROBE", "device_id": "probe-device",
|
|
})
|
|
resp, err := http.Post(base+"/login", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
|
|
fmt.Printf("POST /login HTTP %d\n", resp.StatusCode)
|
|
|
|
var out struct {
|
|
Message string `json:"message"`
|
|
Details struct {
|
|
Token string `json:"token"`
|
|
Expiresat string `json:"expires_at"`
|
|
Tenantid int `json:"tenant_id"`
|
|
Tenantname string `json:"tenant_name"`
|
|
Storeid string `json:"store_id"`
|
|
Locationname string `json:"location_name"`
|
|
Gstin string `json:"gstin"`
|
|
Locations []struct {
|
|
Locationid int `json:"location_id"`
|
|
Locationname string `json:"location_name"`
|
|
} `json:"locations"`
|
|
Staff []struct {
|
|
Fullname string `json:"full_name"`
|
|
Role string `json:"role"`
|
|
} `json:"staff"`
|
|
} `json:"details"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
fmt.Println(string(raw))
|
|
return
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
fmt.Println(" ", out.Message)
|
|
return
|
|
}
|
|
|
|
t := out.Details.Token
|
|
fmt.Printf(" token %s… (%d chars, signature verified below)\n", t[:12], len(t))
|
|
fmt.Printf(" expires %s\n", out.Details.Expiresat)
|
|
fmt.Printf(" tenant %d %s\n", out.Details.Tenantid, out.Details.Tenantname)
|
|
fmt.Printf(" store_id %s (%s)\n", out.Details.Storeid, out.Details.Locationname)
|
|
fmt.Printf(" gstin %s\n", out.Details.Gstin)
|
|
fmt.Printf(" outlets %d\n", len(out.Details.Locations))
|
|
fmt.Printf(" staff %d\n", len(out.Details.Staff))
|
|
for _, s := range out.Details.Staff {
|
|
fmt.Printf(" %s (%s)\n", s.Fullname, s.Role)
|
|
}
|
|
|
|
// The token has to actually open the doors it claims to.
|
|
for _, path := range []string{"/session", "/staff", "/catalogue?store_id=" + out.Details.Storeid + "&page_size=1"} {
|
|
req, _ := http.NewRequest("GET", base+path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+t)
|
|
r, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
b, _ := io.ReadAll(r.Body)
|
|
r.Body.Close()
|
|
fmt.Printf("\nGET %-28s HTTP %d %s", strings.Split(path, "&")[0], r.StatusCode, truncate(string(b), 150))
|
|
}
|
|
|
|
// And must NOT open somebody else's.
|
|
req, _ := http.NewRequest("GET", base+"/catalogue?store_id=1185&page_size=1", nil)
|
|
req.Header.Set("Authorization", "Bearer "+t)
|
|
r, _ := http.DefaultClient.Do(req)
|
|
b, _ := io.ReadAll(r.Body)
|
|
r.Body.Close()
|
|
fmt.Printf("\n\nGET /catalogue (ANOTHER TENANT'S OUTLET 1185) HTTP %d %s\n",
|
|
r.StatusCode, truncate(string(b), 160))
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
if len(s) > n {
|
|
return s[:n] + "…"
|
|
}
|
|
return s
|
|
}
|