Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/polis-cli-differential.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: polis-cli-differential

# Independent client-data proof for every Rust route and CLI change. The
# existing byte replay and nginx/wire gates remain separate checks.
on:
pull_request:
paths:
- 'polis-cli/**'
- 'queue-rs/**'
- 'server/**'
- 'file-server/nginx/**'
- 'oidc-simulator/**'
- 'scripts/*polis-cli*.sh'
- '.github/workflows/polis-cli-differential.yml'
push:
branches: [edge, stable]
paths:
- 'polis-cli/**'
- 'queue-rs/**'
- 'server/**'
- 'file-server/nginx/**'
- 'oidc-simulator/**'
- 'scripts/*polis-cli*.sh'
- '.github/workflows/polis-cli-differential.yml'
workflow_dispatch:

permissions:
contents: read
concurrency:
group: polis-cli-differential-${{ github.ref }}
cancel-in-progress: true

jobs:
differential:
runs-on: ubuntu-24.04
timeout-minutes: 40
env:
COMPOSE_PROJECT_NAME: p027cli-diff-${{ github.run_id }}-${{ github.run_attempt }}
P027_PORT_MIN: '56201'
P027_PORT_MAX: '56203'
POLIS_RECOVERY_PG_PORT: '56201'
RECOVERY_PG_PORT: '56201'
P027_HTTP_PORT: '56202'
P027_CONTROL_PORT: '56203'
CLI_DIFF_NODE_IMAGE: cli-diff-node:ci
CLI_DIFF_POSTGRES_IMAGE: cli-diff-postgres:ci
CLI_DIFF_OIDC_IMAGE: cli-diff-oidc:ci
CLI_DIFF_RUST_IMAGE: cli-diff-rust:ci
PYTHONDONTWRITEBYTECODE: '1'
CARGO_TARGET_DIR: ${{ github.workspace }}/../target
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Build generated-fixture tools and images
run: bash scripts/prepare-polis-cli-differential.sh
- name: Compare every command with Rust switches off and on
run: bash scripts/test-polis-cli-differential.sh --binary ../target/debug/polis --output "${{ runner.temp }}/differential-results.json"
- name: Safe structured result (no response values or credentials)
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cli-differential
path: ${{ runner.temp }}/differential-results.json
if-no-files-found: warn
retention-days: 7
- name: Remove owned stack on cancellation or failure
if: always()
run: docker compose -p "$COMPOSE_PROJECT_NAME" -f server/characterization/compose.yml -f polis-cli/tests/differential/compose.yml down -v --remove-orphans --timeout 1
4 changes: 4 additions & 0 deletions polis-cli/crates/polis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ name = "polis"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
# Generated-input control for the independent live differential only.
fixture-ids = []

[dependencies]
polis-contracts = { path = "../../../queue-rs/polis-contracts" }
polis-client = { path = "../polis-client" }
Expand Down
34 changes: 33 additions & 1 deletion polis-cli/crates/polis/src/seeds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,32 @@ pub fn validate(raw: &str) -> Result<Vec<Value>> {
}
Ok(rows)
}
// The normal binary has no environment-controlled identity generation. The
// dedicated test build accepts a declared generated UUID so two live executions
// receive the same input; no receipt field is removed from the comparison.
#[cfg(feature = "fixture-ids")]
fn fixture_id(value: &str) -> Result<uuid::Uuid> {
uuid::Uuid::parse_str(value).map_err(|_| Error::input("Invalid fixture statement UUID."))
}
fn statement_id() -> Result<uuid::Uuid> {
#[cfg(feature = "fixture-ids")]
if let Some(value) = std::env::var_os("POLIS_CLI_FIXTURE_UUID") {
return fixture_id(
value
.to_str()
.ok_or_else(|| Error::input("Invalid fixture statement UUID."))?,
);
}
Ok(uuid::Uuid::new_v4())
}
pub fn single(value: &str) -> Result<String> {
text(value)?;
let mut writer = csv::Writer::from_writer(Vec::new());
writer
.write_record(["comment_text", "original_id"])
.map_err(|_| Error::input("Cannot encode statement."))?;
writer
.write_record([value, &uuid::Uuid::new_v4().to_string()])
.write_record([value, &statement_id()?.to_string()])
.map_err(|_| Error::input("Cannot encode statement."))?;
Ok(String::from_utf8(writer.into_inner().unwrap()).unwrap())
}
Expand Down Expand Up @@ -207,3 +225,17 @@ mod tests {
}
}
}

#[cfg(all(test, feature = "fixture-ids"))]
mod fixture_tests {
use super::*;
#[test]
fn declared_id_is_preserved() {
let id = "00000000-0000-4000-8000-000000000027";
assert_eq!(fixture_id(id).unwrap().to_string(), id);
}
#[test]
fn malformed_id_is_rejected() {
assert!(fixture_id("not-a-uuid").is_err());
}
}
82 changes: 82 additions & 0 deletions polis-cli/tests/differential/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# CLI differential loop

Run the real CLI against one generated database with Node routing, reset the
fixture and local credential/files state, then run it again with every merged
Rust route enabled. This is independent of the byte replays: it neither starts
those runners nor reads a recording archive. Byte replays remain the wire gate.

Build the CLI and four local images from the source being tested:

```sh
(cd polis-cli && cargo build --locked -p polis --features fixture-ids)
docker build --target prod --build-arg NODE_ENV=production -t cli-diff-node -f server/Dockerfile server
docker build --build-context queue-rs=queue-rs -t cli-diff-postgres -f server/Dockerfile-db server
docker build -t cli-diff-oidc oidc-simulator
docker build --build-arg FEATURES=fixture-clock -t cli-diff-rust -f queue-rs/polis-api/Dockerfile queue-rs
```

Choose an unused project and three unused ports. The images are explicit and
`up --pull never` prevents a test from silently changing them. The nginx image
is the same pinned tag used by the existing routing tests.

```sh
export COMPOSE_PROJECT_NAME=p027cli-diff-local
export P027_PORT_MIN=56201 P027_PORT_MAX=56203
export POLIS_RECOVERY_PG_PORT=56201 RECOVERY_PG_PORT=56201
export P027_HTTP_PORT=56202 P027_CONTROL_PORT=56203
export CLI_DIFF_NODE_IMAGE=cli-diff-node CLI_DIFF_POSTGRES_IMAGE=cli-diff-postgres
export CLI_DIFF_OIDC_IMAGE=cli-diff-oidc CLI_DIFF_RUST_IMAGE=cli-diff-rust
bash scripts/test-polis-cli-differential.sh \
--binary polis-cli/target/debug/polis --output /tmp/cli-differential.json
```

The runner refuses an existing project and removes only its own containers and
volumes in `finally`, even on an assertion failure. No application services can
reach external providers: Node, Rust, Postgres, DynamoDB and the OIDC simulator
are on an internal network. A small gateway exposes loopback ports and forwards
only to the sealed proxy/control service. No credentials or real data are used.

`cases.py` explicitly names every implemented command. The running binary's
`schema` supplies the independent census. A new implemented command without a
scenario fails the run; planned leaf commands must still return exit 8 in both
modes. Parent commands that also perform an operation (such as `vote`) are
included. Local discovery, authentication, file operations and mounted-router
commands are included even when absent from a server registration inventory.
This is one scenario per command, not exhaustive option/identity coverage.

The comparator ignores JSON object key order only. It retains all data/error
fields, types, arrays, IDs, timestamps, status/exit codes and downloaded file
hashes. It never drops fields, replaces IDs, sorts arrays, retries writes or
accepts a mismatch. The output lists differing JSON paths without secret values
or submitted text. `auth token` is compared privately in memory, never logged.

Generated test clocks, SQL timestamp defaults and server-generated identity
entropy are fixed *before* execution, not erased from results. They live only
in the test entry point and disposable database; the real handlers, access
checks and credential signing run unchanged. The explicit CLI `fixture-ids` feature similarly accepts a generated statement
UUID from `POLIS_CLI_FIXTURE_UUID` before a seed write. Default production builds
have no such override. This does not change OAuth randomness, authentication,
receipts, or the comparison. Using a normal binary deliberately fails the seed
receipt comparison; build the documented test variant.

Some commands need stored analysis artifacts absent from this small fixture.
Their rows are marked `missing-store`, and malformed external-result imports
are marked `invalid-input`. Those rows verify empty/availability/refusal behavior only; they are
not successful export/import, provider, analysis or backend coverage. Planned
commands and local commands are likewise reported separately. Never turn a
connectivity/parser/authentication failure into a successful live scenario.

The real nginx renderer receives `RUST_API_ROUTES` empty and then `pca2`, the
only merged switch at this source version. Access logs must show both whole-presentation and selected-key
math requests served 200 by port 5100, with no Rust
fallback. Other paths continue to use Node; their equality is client-flow
regression evidence, not a Rust implementation claim. A separate route-template census refuses an unaccounted new switch. When adding
a Rust route, extend `routes.json`, add its command scenarios and require its
own positive serving evidence.
Do not count a 502 fallback as Rust parity.

The participation path stays unchanged. The fixture runs one bounded live CLI
request/flow at a time (unimplemented local commands run concurrently and must
make no HTTP requests), uses only the existing Node write routes, and adds no
production schema, pool, lock, queue, provider or Rust write. No load/isolation
performance claim follows from this functional test.
77 changes: 77 additions & 0 deletions polis-cli/tests/differential/cases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Explicit command census; adding an implemented command requires a scenario.

Each scenario declares whether it proves a successful call or only a refusal.
The latter is never counted as a working backend implementation.
"""
C = '2p027generated'
R = 'r2p027generated'

def cases():
rows=[]
def add(command,args=(),*,exit=0,scope='live',stdin=None,actor='owner'):
rows.append(dict(command=command,args=command.split()+list(args),expected_exit=exit,scope=scope,stdin=stdin,actor=actor))
# Local discovery is exercised as commands, not substituted for API calls.
for command,args in [('schema',[]),('help',[]),('completion',['bash']),('tutorial',[]),('agent guide',[]),('conversation share',[C]),('plan upgrade',[]),('plan manage',[])]:
add(command,args,scope='local')
add('auth login',['--with-token'],stdin='owner')
add('auth status');add('auth token',scope='credential')
add('account view');add('plan view')
add('conversation list');add('conversation view',[C]);add('conversation stats',[C])
add('math view',['--conversation',C])
add('api',['/api/v3/users'])
add('join',[C],actor='participant')
for path,args in [
('statement list',[]),('statement view',['0']),('moderate list',['--status','all']),
('participant list',[]),('participant status',[]),('participant xid list',[]),
('vote list',['--pid','0']),('report list',[]),('participant highlight list',[]),
('participant group-members',[]),('participant locations',['--group','0']),
('metadata question list',[]),('metadata answer list',[]),('invite code list',[]),
('invite wave list',[]),('invite list',[]),('invite status',[])]:
add(path,[*args,'--conversation',C])
add('context list');add('conversation uuid',[C]);add('report view',[R])
add('report stats',[R]);add('report statements list',[R])
add('participant export',['--conversation',C,'--output','participants.csv'])
add('participant xid export',['--conversation',C,'--output','xids.csv'])
add('invite export',['--conversation',C,'--output','invites.csv'])
# Stored-result fixtures intentionally have no computation/provider backing.
# These cases prove the current missing-store/error contract, not analysis.
for path,args in [
('topic list',['--conversation',C]),('topic statements',['generated#0#1','--conversation',C]),
('topic hierarchy',['--conversation',C]),('topic proximity',['--conversation',C]),
('topic stats',['--conversation',C]),('report topics',[R]),
('collective list',['--report',R]),('collective view',['generated#0#1']),
('delphi status',['--report',R]),('delphi results',['--report',R]),
('delphi narrative view',['--report',R]),('delphi visualization view',['--report',R]),
('report export',[R,'--type','format.json','--output','report.json'])]:
add(path,args,exit={'topic list':1,'topic statements':6,'topic hierarchy':1,'topic proximity':1,'topic stats':0,'report topics':1,'collective list':6,'collective view':6,'delphi status':6,'delphi results':1,'delphi narrative view':1,'delphi visualization view':6,'report export':0}[path],scope='missing-store')
# Import rejection uses a real file, parsed by the real contract; these do
# not claim a successful provider result import or replace its own suite.
for path in ['collective import','delphi statement import','delphi narrative import']:
add(path,[C,'invalid-result.json','--report',R],exit=2,scope='invalid-input')
for path in ['collective export','delphi statement export']:
add(path,[C,'--report',R,'--topic','generated#0#1','--output',path.replace(' ','-')],exit=6,scope='missing-store')
add('delphi narrative export',[C,'--report',R,'--output','narrative'],exit=6,scope='missing-store')
add('vote',[C,'--statement','0','--agree'],actor='participant')
add('comment',[C,'Generated participant statement'],actor='participant')
add('participant preferences',['--conversation',C,'--translations','true'])
add('moderate accept',['--conversation',C,'--tid','0'])
add('moderate reject',['--conversation',C,'--tid','1'])
add('context create',['--name','Generated CLI context'])
add('metadata question add',['--conversation',C,'--key','Generated question'])
add('metadata answer add',['--conversation',C,'--question','1','--value','Generated choice','--yes'])
add('metadata answer delete',['--conversation',C,'--question','1','--answer','1','--yes'])
add('metadata question delete',['--conversation',C,'--question','1','--yes'])
add('invite code create',['--conversation',C])
add('invite wave create',['--conversation',C,'--per-user','0','--owner-invites','1'])
add('conversation edit',[C,'--topic','Generated edited topic'])
add('conversation close',[C]);add('conversation reopen',[C])
add('seed csv validate',['seeds.csv'],scope='local')
add('seed csv preview',['seeds.csv'],scope='local')
add('seed add',['--conversation',C,'--text','Generated seeded statement'])
add('seed import',['seeds.csv','--conversation',C])
add('report create',['--conversation',C])
add('conversation reserve')
add('conversation create',['--topic','Generated simple creation'])
add('conversation new',['--from','setup.json'])
add('auth logout',scope='local')
return rows
58 changes: 58 additions & 0 deletions polis-cli/tests/differential/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Overlay on server/characterization/compose.yml. All paths relative to it.
services:
postgres:
image: ${CLI_DIFF_POSTGRES_IMAGE:?build the local fixture image}
oidc-simulator:
image: ${CLI_DIFF_OIDC_IMAGE:?build the local OIDC simulator}
server:
image: ${CLI_DIFF_NODE_IMAGE:?build the local Node image}
ports: !reset []
command: [node, /differential/entry.cjs]
volumes:
- ../../polis-cli/tests/differential:/differential:ro
environment:
P027_MARKERS: '0'
ADMIN_UIDS: '[]'
CLI_DIFFERENTIAL_FIXTURE: '1'
file-server:
profiles: [unused]
driver:
profiles: [unused]
polis-api:
image: ${CLI_DIFF_RUST_IMAGE:?build polis-api with fixture-clock}
networks: [sealed]
environment:
MATH_ENV: p027
DEV_MODE: 'true'
NODE_ENV: production
DOMAIN_OVERRIDE: localhost
DATABASE_URL: postgres://postgres@postgres:5432/p027
POLIS_API_DB_TRANSPORT: docker-internal
POLIS_API_DB_PLAIN_HOSTS: postgres
POLIS_API_LISTEN: 0.0.0.0:5100
POLIS_API_FIXTURE_CLOCK: '1700000000000'
depends_on:
postgres: {condition: service_healthy}
proxy:
image: ${CLI_DIFF_NGINX_IMAGE:-nginx:1.21.5-alpine}
entrypoint: [sh, /renderer.sh, nginx, -g, 'daemon off;']
environment:
API_SERVER_PORT: '5000'
RUST_API_ROUTES: ${RUST_API_ROUTES:-}
volumes:
- ../../file-server/nginx/docker-entrypoint.sh:/renderer.sh:ro
- ../../file-server/nginx/rust-api:/etc/nginx/rust-api-templates:ro
- ../../polis-cli/tests/differential/nginx.conf.template:/etc/nginx/conf.d/default.conf.template:ro
networks: [sealed]
depends_on: [server, polis-api]
gateway:
image: ${CLI_DIFF_NODE_IMAGE}
command: [node, /differential/gateway.cjs]
volumes: [../../polis-cli/tests/differential:/differential:ro]
ports:
- '127.0.0.1:${P027_HTTP_PORT}:5000'
- '127.0.0.1:${P027_CONTROL_PORT}:5001'
networks: [sealed, cli]
depends_on: [proxy]
networks:
cli: {}
28 changes: 28 additions & 0 deletions polis-cli/tests/differential/entry.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use strict';
// Test-only generated identity entropy. Never loaded by a product entry point.
// Equal write inputs must yield equal IDs, not comparator masks. Signing keys
// remain fresh RSA keys; this does not replace authentication or any handler.
if (process.env.CLI_DIFFERENTIAL_FIXTURE !== '1' || process.env.P027_NETWORK_INTERNAL !== '1')
throw Error('isolated differential fixture required');
const state = require('/app/characterization/observe.cjs');
const crypto = require('node:crypto');
const entropy = require('/app/characterization/entropy.cjs').entropy();
const seed = state.setSeed;
state.setSeed = hex => { seed(hex); entropy.seed(hex); };
function bytes(size, callback) {
const out = Buffer.from(Array.from({length:size}, () => Math.floor(entropy.next()*256)));
if (callback) { process.nextTick(callback, null, out); return; }
return out;
}
crypto.randomBytes = crypto.pseudoRandomBytes = bytes;
crypto.randomInt = (min, max, callback) => {
if (max === undefined) { max = min; min = 0; }
const value = min + Math.floor(entropy.next()*(max-min));
if (callback) { process.nextTick(callback, null, value); return; }
return value;
};
crypto.randomUUID = () => {
const b = bytes(16); b[6] = (b[6]&15)|64; b[8] = (b[8]&63)|128;
const h = b.toString('hex'); return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
};
require('/app/characterization/entry.cjs');
11 changes: 11 additions & 0 deletions polis-cli/tests/differential/gateway.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use strict';
// The only published service: forward to the sealed proxy/control service.
// No internet destination, handler substitute, retry, or response rewrite.
const http = require('node:http');
for (const [port, host, upstreamPort] of [[5000,'proxy',80],[5001,'server',5001]])
http.createServer((req,res) => {
const upstream=http.request({host,port:upstreamPort,path:req.url,method:req.method,headers:req.headers,agent:false},r=>{
res.writeHead(r.statusCode,r.headers);r.pipe(res);
});
upstream.on('error',()=>{res.writeHead(502);res.end();});req.pipe(upstream);
}).listen(port,'0.0.0.0');
Loading
Loading