Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/client/lib/commands/ACL_GETUSER.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ describe('ACL GETUSER', () => {
);
});

it('transformReply passes a null reply through for nonexistent users', () => {
assert.equal(
(ACL_GETUSER.transformReply[2] as unknown as (reply: unknown) => unknown)(null),
null
);
});

testUtils.testWithClient('client.aclGetUser', async client => {
const reply = await client.aclGetUser('default');

Expand Down
40 changes: 23 additions & 17 deletions packages/client/lib/commands/ACL_GETUSER.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CommandParser } from '../client/parser';
import { RedisArgument, TuplesToMapReply, BlobStringReply, ArrayReply, UnwrapReply, Resp2Reply, Command } from '../RESP/types';
import { RedisArgument, TuplesToMapReply, BlobStringReply, ArrayReply, UnwrapReply, Resp2Reply, Command, NullReply } from '../RESP/types';
import { isNullReply } from './generic-transformers';

type AclUser = TuplesToMapReply<[
[BlobStringReply<'flags'>, ArrayReply<BlobStringReply>],
Expand All @@ -23,21 +24,26 @@ export default {
parser.push('ACL', 'GETUSER', username);
},
transformReply: {
2: (reply: UnwrapReply<Resp2Reply<AclUser>>) => ({
flags: reply[1],
passwords: reply[3],
commands: reply[5],
keys: reply[7],
channels: reply[9],
selectors: (reply[11] as unknown as UnwrapReply<typeof reply[11]>)?.map(selector => {
const inferred = selector as unknown as UnwrapReply<typeof selector>;
return {
commands: inferred[1],
keys: inferred[3],
channels: inferred[5]
};
})
}),
3: undefined as unknown as () => AclUser
// NullReply when the user does not exist
2: (reply: UnwrapReply<Resp2Reply<AclUser>> | NullReply) => {
if (isNullReply(reply)) return reply;

return {
flags: reply[1],
passwords: reply[3],
commands: reply[5],
keys: reply[7],
channels: reply[9],
selectors: (reply[11] as unknown as UnwrapReply<typeof reply[11]>)?.map(selector => {
const inferred = selector as unknown as UnwrapReply<typeof selector>;
return {
commands: inferred[1],
keys: inferred[3],
channels: inferred[5]
};
})
};
},
3: undefined as unknown as () => AclUser | NullReply
}
} as const satisfies Command;
22 changes: 22 additions & 0 deletions packages/client/types-tests/acl-getuser.types-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Compile-time regression for https://github.com/redis/node-redis/issues/2745.
*
* ACL GETUSER replies null for a nonexistent user, but the declared reply type
* claimed a non-null object shape and the RESP2 transformer indexed into the
* null reply. These assertions fail to compile against the unfixed code.
*
* Lives outside lib/ so it is not picked up by the production build / typedoc.
* Checked with `npm run test:types -w @redis/client`.
*/
import type { CommandReply, ReplyWithTypeMapping } from '../lib/RESP/types';
import type ACL_GETUSER from '../lib/commands/ACL_GETUSER';

type Assert<T extends true> = T;

// The default (RESP3) client-facing reply type must include null.
type Resp3ClientReply = ReplyWithTypeMapping<CommandReply<typeof ACL_GETUSER, 3>, {}>;
export type Resp3ClientReplyIncludesNull = Assert<null extends Resp3ClientReply ? true : false>;

// The RESP2 client-facing reply type must include null too.
type Resp2ClientReply = ReplyWithTypeMapping<CommandReply<typeof ACL_GETUSER, 2>, {}>;
export type Resp2ClientReplyIncludesNull = Assert<null extends Resp2ClientReply ? true : false>;